Refine titlebar controls and clear notifications on close

This commit is contained in:
Lawrence Chen 2026-01-28 17:35:48 -08:00
parent 4c7005f54d
commit f0e2efe8e4
13 changed files with 559 additions and 110 deletions

View file

@ -7,6 +7,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
weak var tabManager: TabManager?
weak var notificationStore: TerminalNotificationStore?
weak var sidebarState: SidebarState?
private var workspaceObserver: NSObjectProtocol?
private let updateController = UpdateController()
private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel)
@ -61,9 +62,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
notificationStore?.clearAll()
}
func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore) {
func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) {
self.tabManager = tabManager
self.notificationStore = notificationStore
self.sidebarState = sidebarState
}
@objc func checkForUpdates(_ sender: Any?) {

View file

@ -1,10 +1,19 @@
import AppKit
import SwiftUI
final class SidebarState: ObservableObject {
@Published var isVisible: Bool = true
func toggle() {
isVisible.toggle()
}
}
struct ContentView: View {
@ObservedObject var updateViewModel: UpdateViewModel
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
@EnvironmentObject var sidebarState: SidebarState
@State private var sidebarWidth: CGFloat = 200
@State private var sidebarMinX: CGFloat = 0
@State private var isResizerHovering = false
@ -17,9 +26,8 @@ struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
// Vertical Tabs Sidebar
if sidebarState.isVisible {
VerticalTabsSidebar(
sidebarWidth: sidebarWidth,
selection: $sidebarSelection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
@ -79,23 +87,24 @@ struct ContentView: View {
}
)
}
}
// Terminal Content - use ZStack to keep all surfaces alive
// Terminal Content - use ZStack to keep all surfaces alive
ZStack {
ZStack {
ZStack {
ForEach(tabManager.tabs) { tab in
let isActive = tabManager.selectedTabId == tab.id
TerminalSplitTreeView(tab: tab, isTabActive: isActive)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
.focusable()
.focused($focusedTabId, equals: tab.id)
}
ForEach(tabManager.tabs) { tab in
let isActive = tabManager.selectedTabId == tab.id
TerminalSplitTreeView(tab: tab, isTabActive: isActive)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
.focusable()
.focused($focusedTabId, equals: tab.id)
}
.opacity(sidebarSelection == .tabs ? 1 : 0)
.allowsHitTesting(sidebarSelection == .tabs)
}
.opacity(sidebarSelection == .tabs ? 1 : 0)
.allowsHitTesting(sidebarSelection == .tabs)
NotificationsPage(selection: $sidebarSelection)
NotificationsPage(selection: $sidebarSelection)
.opacity(sidebarSelection == .notifications ? 1 : 0)
.allowsHitTesting(sidebarSelection == .notifications)
}
@ -144,86 +153,46 @@ struct ContentView: View {
})
}
private func addTab() {
tabManager.addTab()
sidebarSelection = .tabs
}
}
struct VerticalTabsSidebar: View {
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
let sidebarWidth: CGFloat
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
var body: some View {
VStack(spacing: 0) {
// Header with title
HStack {
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)
GeometryReader { proxy in
ScrollView {
VStack(spacing: 0) {
LazyVStack(spacing: 2) {
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
TabItemView(
tab: tab,
index: index,
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
}
}
}
.buttonStyle(.plain)
.foregroundColor(selection == .notifications ? .primary : .secondary)
.padding(.vertical, 8)
Button(action: { tabManager.addTab() }) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
SidebarEmptyArea(
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
Divider()
// Tab List
GeometryReader { proxy in
ScrollView {
VStack(spacing: 0) {
LazyVStack(spacing: 2) {
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
TabItemView(
tab: tab,
index: index,
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
}
}
.padding(.vertical, 4)
SidebarEmptyArea(
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minHeight: proxy.size.height, alignment: .top)
}
.accessibilityIdentifier("Sidebar")
.frame(minHeight: proxy.size.height, alignment: .top)
}
.accessibilityIdentifier("Sidebar")
}
.background(Color(nsColor: .controlBackgroundColor))
}

View file

@ -67,21 +67,20 @@ fileprivate struct TerminalSplitSubtreeView: View {
)
.background(Color.clear)
if notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surface.id) {
Circle()
.stroke(Color(nsColor: .systemBlue), lineWidth: 2.5)
.frame(width: 14, height: 14)
.shadow(color: Color(nsColor: .systemBlue).opacity(0.35), radius: 2)
.padding(6)
.allowsHitTesting(false)
}
if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 {
Rectangle()
.fill(appearance.unfocusedOverlayColor)
.opacity(appearance.unfocusedOverlayOpacity)
.allowsHitTesting(false)
}
if notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surface.id) {
Rectangle()
.stroke(Color(nsColor: .systemBlue), lineWidth: 2.5)
.shadow(color: Color(nsColor: .systemBlue).opacity(0.35), radius: 3)
.padding(2)
.allowsHitTesting(false)
}
}
case .split(let split):
let splitViewDirection: SplitViewDirection = switch split.direction {

View file

@ -49,6 +49,7 @@ class Tab: Identifiable, ObservableObject {
guard isSelectedTab && isAppFocused else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) {
triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false)
notificationStore.markRead(forTabId: self.id, surfaceId: id)
return
}
@ -347,6 +348,8 @@ class TabManager: ObservableObject {
func closeTab(_ tab: Tab) {
guard tabs.count > 1 else { return }
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
tabs.remove(at: index)
@ -383,7 +386,7 @@ class TabManager: ObservableObject {
) else { return }
}
_ = tab.closeSurface(focusedSurfaceId)
_ = closeSurface(tabId: selectedId, surfaceId: focusedSurfaceId)
}
func closeCurrentTabWithConfirmation() {
@ -466,6 +469,9 @@ class TabManager: ObservableObject {
guard let surfaceId = focusedSurfaceId(for: tabId) else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }
if let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
}
@ -612,6 +618,7 @@ class TabManager: ObservableObject {
guard let tabIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false }
let tab = tabs[tabIndex]
guard tab.closeSurface(surfaceId) else { return false }
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tabId, surfaceId: surfaceId)
if tab.splitTree.isEmpty {
if tabs.count > 1 {

View file

@ -119,12 +119,10 @@ class TerminalController {
guard !trimmed.isEmpty else { continue }
let response = processCommand(trimmed)
response.withCString { ptr in
let payload = response + "\n"
payload.withCString { ptr in
_ = write(socket, ptr, strlen(ptr))
}
"\n".withCString { ptr in
_ = write(socket, ptr, 1)
}
}
}
}

View file

@ -149,6 +149,17 @@ final class TerminalNotificationStore: ObservableObject {
}
}
func clearNotifications(forTabId tabId: UUID) {
let ids = notifications
.filter { $0.tabId == tabId }
.map { $0.id.uuidString }
notifications.removeAll { $0.tabId == tabId }
if !ids.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: ids)
center.removePendingNotificationRequests(withIdentifiers: ids)
}
}
private func scheduleUserNotification(_ notification: TerminalNotification) {
ensureAuthorization { [weak self] authorized in
guard let self, authorized else { return }

View file

@ -84,6 +84,259 @@ private struct TitlebarAccessoryView: View {
}
}
private struct TitlebarControlsView: View {
@ObservedObject var notificationStore: TerminalNotificationStore
let onToggleSidebar: () -> Void
let onNewTab: () -> Void
@State private var isShowingNotifications = false
var body: some View {
HStack(spacing: 10) {
Button(action: onToggleSidebar) {
Image(systemName: "sidebar.left")
.font(.system(size: 15, weight: .semibold))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.accessibilityLabel("Toggle Sidebar")
Button(action: { isShowingNotifications.toggle() }) {
ZStack(alignment: .topTrailing) {
Image(systemName: "bell")
.font(.system(size: 15, weight: .semibold))
.frame(width: 24, height: 24)
if notificationStore.unreadCount > 0 {
Text("\(min(notificationStore.unreadCount, 99))")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.white)
.frame(width: 14, height: 14)
.background(
Circle().fill(Color.accentColor)
)
.offset(x: 2, y: -2)
}
}
.frame(width: 26, height: 24)
}
.buttonStyle(.plain)
.accessibilityLabel("Notifications")
.popover(isPresented: $isShowingNotifications, arrowEdge: .top) {
NotificationsPopoverView(notificationStore: notificationStore)
}
Button(action: onNewTab) {
Image(systemName: "plus")
.font(.system(size: 15, weight: .semibold))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.accessibilityLabel("New Tab")
}
.padding(.leading, 4)
}
}
final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController {
private let hostingView: NonDraggableHostingView<TitlebarControlsView>
private let containerView = NSView()
private var pendingSizeUpdate = false
init(notificationStore: TerminalNotificationStore) {
let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() }
let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() }
hostingView = NonDraggableHostingView(
rootView: TitlebarControlsView(
notificationStore: notificationStore,
onToggleSidebar: toggleSidebar,
onNewTab: newTab
)
)
super.init(nibName: nil, bundle: nil)
view = containerView
containerView.translatesAutoresizingMaskIntoConstraints = true
hostingView.translatesAutoresizingMaskIntoConstraints = true
hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)
scheduleSizeUpdate()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidAppear() {
super.viewDidAppear()
scheduleSizeUpdate()
}
override func viewDidLayout() {
super.viewDidLayout()
scheduleSizeUpdate()
}
private func scheduleSizeUpdate() {
guard !pendingSizeUpdate else { return }
pendingSizeUpdate = true
DispatchQueue.main.async { [weak self] in
self?.pendingSizeUpdate = false
self?.updateSize()
}
}
private func updateSize() {
hostingView.invalidateIntrinsicContentSize()
hostingView.layoutSubtreeIfNeeded()
let contentSize = hostingView.fittingSize
let titlebarHeight = view.window.map { window in
window.frame.height - window.contentLayoutRect.height
} ?? contentSize.height
let containerHeight = max(contentSize.height, titlebarHeight)
let yOffset = max(0, (containerHeight - contentSize.height) / 2.0)
preferredContentSize = NSSize(width: contentSize.width, height: containerHeight)
containerView.frame = NSRect(x: 0, y: 0, width: contentSize.width, height: containerHeight)
hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height)
}
}
private struct NotificationsPopoverView: View {
@ObservedObject var notificationStore: TerminalNotificationStore
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Notifications")
.font(.headline)
Spacer()
if !notificationStore.notifications.isEmpty {
Button("Clear All") {
notificationStore.clearAll()
}
.buttonStyle(.bordered)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
Divider()
if notificationStore.notifications.isEmpty {
VStack(spacing: 8) {
Image(systemName: "bell.slash")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text("No notifications yet")
.font(.headline)
Text("Desktop notifications will appear here.")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(width: 320, height: 180)
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(notificationStore.notifications) { notification in
NotificationPopoverRow(
notification: notification,
tabTitle: tabTitle(for: notification.tabId),
onOpen: { open(notification) },
onClear: { notificationStore.remove(id: notification.id) }
)
}
}
.padding(12)
}
.frame(width: 360, height: 360)
}
}
.background(Color(nsColor: .windowBackgroundColor))
}
private func tabTitle(for tabId: UUID) -> String? {
AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == tabId })?.title
}
private func open(_ notification: TerminalNotification) {
AppDelegate.shared?.tabManager?.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId)
markReadIfFocused(notification)
}
private func markReadIfFocused(_ notification: TerminalNotification) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
guard let tabManager = AppDelegate.shared?.tabManager else { return }
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 NotificationPopoverRow: View {
let notification: TerminalNotification
let tabTitle: String?
let onOpen: () -> Void
let onClear: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
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: 4) {
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(10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(nsColor: .controlBackgroundColor))
)
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
}
}
final class UpdateAccessoryViewController: NSTitlebarAccessoryViewController {
private let hostingView: NonDraggableHostingView<TitlebarAccessoryView>
private let containerView = NSView()
@ -156,6 +409,7 @@ final class UpdateTitlebarAccessoryController {
private var stateCancellable: AnyCancellable?
private var lastIsIdle: Bool?
private let updateIdentifier = NSUserInterfaceItemIdentifier("cmux.updateAccessory")
private let controlsIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarControls")
#if DEBUG
private let devIdentifier = NSUserInterfaceItemIdentifier("cmux.devAccessory")
#endif
@ -213,6 +467,16 @@ final class UpdateTitlebarAccessoryController {
guard let updateViewModel else { return }
guard !attachedWindows.contains(window) else { return }
guard window.styleMask.contains(.titled) else { return }
guard !isSettingsWindow(window) else { return }
if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) {
let controls = TitlebarControlsAccessoryViewController(
notificationStore: TerminalNotificationStore.shared
)
controls.layoutAttribute = .left
controls.view.identifier = controlsIdentifier
window.addTitlebarAccessoryViewController(controls)
}
#if DEBUG
if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == devIdentifier }) {
@ -233,6 +497,13 @@ final class UpdateTitlebarAccessoryController {
attachedWindows.add(window)
}
private func isSettingsWindow(_ window: NSWindow) -> Bool {
if window.identifier?.rawValue == "cmux.settings" {
return true
}
return window.title == "Settings"
}
private func installStateObserver() {
guard let updateViewModel else { return }
stateCancellable = Publishers.CombineLatest(updateViewModel.$state, updateViewModel.$overrideState)

View file

@ -5,6 +5,8 @@ import SwiftUI
struct cmuxApp: App {
@StateObject private var tabManager = TabManager()
@StateObject private var notificationStore = TerminalNotificationStore.shared
@StateObject private var sidebarState = SidebarState()
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
@ -17,13 +19,21 @@ struct cmuxApp: App {
ContentView(updateViewModel: appDelegate.updateViewModel)
.environmentObject(tabManager)
.environmentObject(notificationStore)
.environmentObject(sidebarState)
.onAppear {
// Start the Unix socket controller for programmatic access
TerminalController.shared.start(tabManager: tabManager)
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore)
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
applyAppearance()
}
.onChange(of: appearanceMode) { _ in
applyAppearance()
}
}
.windowToolbarStyle(.automatic)
Settings {
SettingsRootView()
}
.commands {
CommandGroup(replacing: .appInfo) {
Button("About cmuxterm") {
@ -97,6 +107,13 @@ struct cmuxApp: App {
// Tab navigation
CommandGroup(after: .toolbar) {
Button("Toggle Sidebar") {
sidebarState.toggle()
}
.keyboardShortcut("b", modifiers: .command)
Divider()
Button("Next Tab") {
tabManager.selectNextTab()
}
@ -148,4 +165,54 @@ struct cmuxApp: App {
])
NSApp.activate(ignoringOtherApps: true)
}
private func applyAppearance() {
guard let mode = AppearanceMode(rawValue: appearanceMode) else { return }
switch mode {
case .auto:
NSApp.appearance = nil
case .system:
let match = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) ?? .aqua
NSApp.appearance = NSAppearance(named: match)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
}
}
}
enum AppearanceMode: String, CaseIterable, Identifiable {
case auto
case system
case dark
var id: String { rawValue }
}
struct SettingsView: View {
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Theme")
.font(.headline)
Picker("Theme", selection: $appearanceMode) {
Text("Auto").tag(AppearanceMode.auto.rawValue)
Text("System").tag(AppearanceMode.system.rawValue)
Text("Dark").tag(AppearanceMode.dark.rawValue)
}
.pickerStyle(.radioGroup)
}
.padding(20)
.frame(minWidth: 360, minHeight: 180)
}
}
private struct SettingsRootView: View {
var body: some View {
SettingsView()
.background(WindowAccessor { window in
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
})
}
}

View file

@ -1,7 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="/Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmuxterm DEV.app"
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
pkill -x "cmuxterm DEV" || true
sleep 0.2
open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmuxterm\ DEV.app
open "$APP_PATH"
osascript -e 'tell application "cmuxterm DEV" to activate' || true

View file

@ -1,6 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/reload.sh &
./scripts/reloadp.sh &
wait
./scripts/reload.sh
./scripts/reloadp.sh

View file

@ -1,7 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="/Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmuxterm.app"
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -destination 'platform=macOS' build
pkill -x cmuxterm || true
sleep 0.2
open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmuxterm.app
open "$APP_PATH"
osascript -e 'tell application "cmuxterm" to activate' || true

View file

@ -29,6 +29,7 @@ Usage:
"""
import socket
import select
import os
from typing import Optional, List, Tuple
@ -46,6 +47,7 @@ class cmux:
def __init__(self, socket_path: str = None):
self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH
self._socket: Optional[socket.socket] = None
self._recv_buffer: str = ""
def connect(self) -> None:
"""Connect to the cmux socket"""
@ -87,8 +89,25 @@ class cmux:
try:
self._socket.sendall((command + "\n").encode())
response = self._socket.recv(8192).decode().strip()
return response
data = self._recv_buffer
self._recv_buffer = ""
while True:
if "\n" not in data:
chunk = self._socket.recv(8192)
if not chunk:
break
data += chunk.decode()
continue
ready, _, _ = select.select([self._socket], [], [], 0.01)
if not ready:
break
chunk = self._socket.recv(8192)
if not chunk:
break
data += chunk.decode()
if data.endswith("\n"):
data = data[:-1]
return data
except socket.timeout:
raise cmuxError("Command timed out")
except socket.error as e:

View file

@ -52,6 +52,23 @@ def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
return surfaces
def focused_surface_index(client: cmux) -> int:
surfaces = client.list_surfaces()
focused = next((s for s in surfaces if s[2]), None)
if focused is None:
raise RuntimeError("No focused surface")
return focused[0]
def send_osc(client: cmux, sequence: str, surface: int | None = None) -> None:
"""Send an OSC sequence by printing it in the shell."""
command = f"printf '{sequence}'\\n"
if surface is None:
client.send(command)
else:
client.send_surface(surface, command)
def test_clear_prior_notifications(client: cmux) -> TestResult:
result = TestResult("Clear Prior Panel Notifications")
try:
@ -106,10 +123,60 @@ def test_not_suppressed_when_inactive(client: cmux) -> TestResult:
return result
def test_kitty_notification_simple(client: cmux) -> TestResult:
result = TestResult("Kitty OSC 99 Simple")
try:
client.clear_notifications()
client.set_app_focus(False)
surface = focused_surface_index(client)
send_osc(client, "\\x1b]99;;Kitty Simple\\x1b\\\\", surface)
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
elif items[0]["title"] != "Kitty Simple":
result.failure(f"Expected title 'Kitty Simple', got '{items[0]['title']}'")
else:
result.success("OSC 99 simple notification received")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_kitty_notification_chunked(client: cmux) -> TestResult:
result = TestResult("Kitty OSC 99 Chunked Title/Body")
try:
client.clear_notifications()
client.set_app_focus(False)
# Avoid Ghostty's 1s desktop notification rate limit.
time.sleep(1.1)
surface = focused_surface_index(client)
send_osc(client, "\\x1b]99;i=kitty:d=0:p=title;Kitty Title\\x1b\\\\", surface)
time.sleep(0.1)
items = client.list_notifications()
if items:
result.failure("Expected no notification before final chunk")
return result
send_osc(client, "\\x1b]99;i=kitty:p=body;Kitty Body\\x1b\\\\", surface)
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
elif items[0]["title"] != "Kitty Title" or items[0]["body"] != "Kitty Body":
result.failure(
f"Expected title/body 'Kitty Title'/'Kitty Body', got "
f"'{items[0]['title']}'/'{items[0]['body']}'"
)
else:
result.success("OSC 99 chunked notification received")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Mark Read On Panel Focus")
try:
client.clear_notifications()
client.reset_flash_counts()
surfaces = ensure_two_surfaces(client)
focused = next((s for s in surfaces if s[2]), None)
other = next((s for s in surfaces if not s[2]), None)
@ -131,6 +198,8 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult:
result.failure("Expected notification for target surface")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on focus")
elif client.flash_count(other[1]) < 1:
result.failure("Expected flash on panel focus dismissal")
else:
result.success("Notification marked read on focus")
except Exception as e:
@ -195,8 +264,8 @@ def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
return result
def test_no_flash_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("No Flash On Tab Switch")
def test_flash_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Flash On Tab Switch")
try:
client.clear_notifications()
client.reset_flash_counts()
@ -220,10 +289,10 @@ def test_no_flash_on_tab_switch(client: cmux) -> TestResult:
time.sleep(0.2)
count = client.flash_count(focused[1])
if count != 0:
result.failure(f"Expected flash count 0, got {count}")
if count < 1:
result.failure(f"Expected flash count >= 1, got {count}")
else:
result.success("No flash triggered on tab switch")
result.success("Flash triggered on tab switch dismissal")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -303,18 +372,50 @@ def test_restore_focus_on_tab_switch(client: cmux) -> TestResult:
return result
def test_clear_on_tab_close(client: cmux) -> TestResult:
result = TestResult("Clear On Tab Close")
try:
client.clear_notifications()
client.set_app_focus(False)
tab1 = client.current_tab()
client.notify("closetab")
time.sleep(0.1)
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
return result
client.new_tab()
time.sleep(0.1)
client.close_tab(tab1)
time.sleep(0.2)
items = client.list_notifications()
if items:
result.failure(f"Expected 0 notifications after tab close, got {len(items)}")
else:
result.success("Notifications cleared when tab closed")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def run_tests() -> int:
results = []
with cmux() as client:
results.append(test_clear_prior_notifications(client))
results.append(test_suppress_when_focused(client))
results.append(test_not_suppressed_when_inactive(client))
results.append(test_kitty_notification_simple(client))
results.append(test_kitty_notification_chunked(client))
results.append(test_mark_read_on_focus_change(client))
results.append(test_mark_read_on_app_active(client))
results.append(test_mark_read_on_tab_switch(client))
results.append(test_no_flash_on_tab_switch(client))
results.append(test_flash_on_tab_switch(client))
results.append(test_focus_on_notification_click(client))
results.append(test_restore_focus_on_tab_switch(client))
results.append(test_clear_on_tab_close(client))
client.set_app_focus(None)
client.clear_notifications()