Disable native tabbing and polish notifications
This commit is contained in:
parent
8320d5805a
commit
f275782a9c
5 changed files with 74 additions and 163 deletions
161
CLAUDE.md
161
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: <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`.
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue