Disable native tabbing and polish notifications

This commit is contained in:
Lawrence Chen 2026-01-26 03:28:54 -08:00
parent 8320d5805a
commit f275782a9c
5 changed files with 74 additions and 163 deletions

161
CLAUDE.md
View file

@ -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: <UUID> <title>` (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`.

View file

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

View file

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

View file

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

View file

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