diff --git a/CLAUDE.md b/CLAUDE.md index 7b6b24b9..7999792f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,156 +1,17 @@ -# cmux +# GhosttyTabs agent notes -A macOS terminal app with vertical tabs, using libghostty (GhosttyKit.xcframework) for terminal emulation. +## Release -## User Preferences - -- **Always use Release builds** when building and launching for testing -- Build libghostty with `-Doptimize=ReleaseFast` for performance - -## Development - -### Build and launch (Release) -```bash -cd /Users/lawrencechen/fun/cmux-terminal/GhosttyTabs -pkill -9 cmux 2>/dev/null -xcodebuild -scheme cmux -configuration Release build -open ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmux.app -``` - -### Rebuild libghostty (optimized) -```bash -cd /tmp/ghostty -zig build -Demit-xcframework=true -Dxcframework-target=native -Doptimize=ReleaseFast -cp -R /tmp/ghostty/macos/GhosttyKit.xcframework /Users/lawrencechen/fun/cmux-terminal/GhosttyTabs/ -``` - -### Project structure -- `Sources/` - Swift source files - - `cmuxApp.swift` - App entry point with keyboard shortcuts - - `ContentView.swift` - Main UI with vertical tabs sidebar - - `TabManager.swift` - Tab state management - - `GhosttyTerminalView.swift` - libghostty terminal integration - - `GhosttyConfig.swift` - Ghostty config parser - - `TerminalController.swift` - Unix socket server for programmatic control -- `tests/` - Test files and utilities - - `cmux.py` - Python client library for socket API - - `test_ctrl_socket.py` - Main automated test suite -- `GhosttyKit.xcframework/` - libghostty static library (gitignored, rebuild from /tmp/ghostty) -- `ghostty.h` - Ghostty C API header -- `cmux-Bridging-Header.h` - Swift bridging header - -### Keyboard Shortcuts -- `Cmd+T` / `Cmd+N` / `Ctrl+Shift+`` - New tab -- `Cmd+W` - Close tab -- `Cmd+Shift+]` / `Ctrl+Tab` - Next tab -- `Cmd+Shift+[` / `Ctrl+Shift+Tab` - Previous tab -- `Cmd+1-9` - Jump to tab by number - -### Config -Reads user's Ghostty config from: -`~/Library/Application Support/com.mitchellh.ghostty/config` - -## Testing - -### Unix Socket Control API - -cmux exposes a Unix socket at `/tmp/cmux.sock` for programmatic control and automated testing. The socket is created when the app launches. - -#### Socket Commands - -Text-based protocol with newline-delimited commands: - -| Command | Description | Response | -|---------|-------------|----------| -| `ping` | Check if server is running | `PONG` | -| `list_tabs` | List all tabs | `* 0: ` (per line) | -| `new_tab` | Create a new tab | `OK <UUID>` | -| `close_tab <id>` | Close tab by UUID | `OK` or `ERROR: ...` | -| `select_tab <id\|index>` | Select tab by UUID or index | `OK` or `ERROR: ...` | -| `current_tab` | Get current tab UUID | `<UUID>` | -| `send <text>` | Send text to terminal | `OK` | -| `send_key <key>` | Send special key | `OK` | -| `help` | Show available commands | Help text | - -#### Special Keys for `send_key` - -- `ctrl-c`, `ctrl-d`, `ctrl-z`, `ctrl-\` - Control signals -- `enter`, `tab`, `escape`, `backspace` - Common keys -- `ctrl-<letter>` - Any control+letter combination - -#### Text Escaping for `send` - -Use `\n` for Enter (carriage return), `\t` for tab, `\r` for raw CR. - -### Python Client Library - -Located at `tests/cmux.py`: - -```python -from cmux import cmux - -with cmux() as client: - # Send text with Enter - client.send("echo hello\n") - - # Send special keys - client.send_ctrl_c() # Interrupt - client.send_ctrl_d() # EOF - client.send_key("enter") - - # Tab management - tabs = client.list_tabs() - client.new_tab() - client.select_tab(0) -``` - -### Running Tests +Tagging a version triggers the GitHub Actions release workflow and uploads the notarized zip. ```bash -# Build and launch the app first -pkill -9 cmux 2>/dev/null -xcodebuild -scheme cmux -configuration Release build -open ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmux.app -sleep 3 - -# Run the main test suite (tests Ctrl+C, Ctrl+D) -python3 tests/test_ctrl_socket.py - -# Interactive CLI for manual testing -python3 tests/cmux.py +git tag vX.Y.Z +git push origin vX.Y.Z +gh run watch --repo manaflow-ai/GhosttyTabs ``` -### Writing New Tests - -1. **Use marker files for verification** - Create temp files to verify commands executed: - ```python - marker = Path(tempfile.gettempdir()) / f"test_marker_{os.getpid()}" - client.send(f"touch {marker}\n") - time.sleep(0.5) - assert marker.exists() - ``` - -2. **Allow settling time** - Terminal commands need time to execute: - ```python - client.send("sleep 5\n") - time.sleep(0.3) # Wait for command to start - client.send_ctrl_c() - time.sleep(0.3) # Wait for interrupt - ``` - -3. **Clean up marker files** - Always remove test artifacts: - ```python - try: - # test code - finally: - marker.unlink(missing_ok=True) - ``` - -### Test Files - -- `tests/cmux.py` - Python client library for socket API -- `tests/test_ctrl_socket.py` - Automated Ctrl+C/D test suite (main tests) -- `tests/test_signals_auto.py` - PTY-based signal tests (standalone) -- `tests/test_ctrl_interactive.py` - Interactive manual tests -- `tests/test_ctrl_signals.sh` - Simple bash signal test -- `tests/test_app_keystrokes.sh` - AppleScript keystroke tests (deprecated) +Notes: +- Requires GitHub secrets: `APPLE_CERTIFICATE_BASE64`, `APPLE_CERTIFICATE_PASSWORD`, + `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`. +- The release asset is `GhosttyTabs-macos.zip` attached to the tag. +- README download button points to `releases/latest/download/GhosttyTabs-macos.zip`. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 4eeba9a1..02445b67 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -342,7 +342,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -356,7 +356,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -385,7 +385,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -399,7 +399,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -426,10 +426,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -443,10 +443,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 304a7a4c..b3f04571 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -17,11 +17,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationDidFinishLaunching(_ notification: Notification) { registerLaunchServicesBundle() enforceSingleInstance() + NSWindow.allowsAutomaticWindowTabbing = false + disableNativeTabbingShortcut() ensureApplicationIcon() observeDuplicateLaunches() configureUserNotifications() } + func applicationWillTerminate(_ notification: Notification) { + notificationStore?.clearAll() + } + func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore) { self.tabManager = tabManager self.notificationStore = notificationStore @@ -47,6 +53,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent center.delegate = self } + private func disableNativeTabbingShortcut() { + guard let menu = NSApp.mainMenu else { return } + disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) + } + + private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { + for item in menu.items { + if item.action == action { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + item.isEnabled = false + } + if let submenu = item.submenu { + disableMenuItemShortcut(in: submenu, action: action) + } + } + } + private func ensureApplicationIcon() { if let icon = NSImage(named: NSImage.applicationIconName) { NSApplication.shared.applicationIconImage = icon diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2dd79c6c..87b0668d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -344,14 +344,15 @@ class GhosttyApp { 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 + let command = actionTitle.isEmpty ? tabTitle : actionTitle + let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) DispatchQueue.main.async { tabManager.moveTabToTop(tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, - title: tabTitle, + title: command, body: body ) } @@ -500,13 +501,14 @@ class GhosttyApp { 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 + let command = actionTitle.isEmpty ? tabTitle : actionTitle + let body = actionBody DispatchQueue.main.async { AppDelegate.shared?.tabManager?.moveTabToTop(tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, - title: tabTitle, + title: command, body: body ) } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 70f5e71e..8615c139 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -22,6 +22,7 @@ final class TerminalNotificationStore: ObservableObject { private let center = UNUserNotificationCenter.current() private var hasRequestedAuthorization = false + private var hasPromptedForSettings = false private init() {} @@ -96,7 +97,11 @@ final class TerminalNotificationStore: ObservableObject { guard let self, authorized else { return } let content = UNMutableNotificationContent() - content.title = notification.title + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "cmux" + content.title = appName + content.subtitle = notification.title content.body = notification.body content.sound = UNNotificationSound.default content.categoryIdentifier = Self.categoryIdentifier @@ -133,6 +138,7 @@ final class TerminalNotificationStore: ObservableObject { case .authorized, .provisional, .ephemeral: completion(true) case .denied: + self.promptToEnableNotifications() completion(false) case .notDetermined: self.requestAuthorizationIfNeeded(completion) @@ -152,4 +158,22 @@ final class TerminalNotificationStore: ObservableObject { completion(granted) } } + + private func promptToEnableNotifications() { + DispatchQueue.main.async { [weak self] in + guard let self, !self.hasPromptedForSettings else { return } + self.hasPromptedForSettings = true + + let alert = NSAlert() + alert.messageText = "Enable Notifications for cmux" + alert.informativeText = "Notifications are disabled for cmux. Enable them in System Settings to see alerts." + alert.addButton(withTitle: "Open Settings") + alert.addButton(withTitle: "Not Now") + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } + } + } }