Add cmuxterm CLI and socket control modes

This commit is contained in:
Lawrence Chen 2026-01-28 21:19:48 -08:00
parent c5d6065664
commit a0bf5dfc84
22 changed files with 1446 additions and 92 deletions

View file

@ -2,12 +2,24 @@
## Local dev ## Local dev
After making code changes, always run the reload script to launch the Debug app:
```bash
./scripts/reload.sh
```
After making code changes, always run the build: After making code changes, always run the build:
```bash ```bash
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
``` ```
When rebuilding GhosttyKit.xcframework, always use Release optimizations:
```bash
cd ghostty && zig build -Demit-xcframework=true -Doptimize=ReleaseFast
```
`reload` = kill and launch the Debug app only: `reload` = kill and launch the Debug app only:
```bash ```bash
@ -28,12 +40,20 @@ xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -des
## E2E mac UI tests ## E2E mac UI tests
Run UI tests on the UTM macOS VM (never on the host machine): Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:
```bash ```bash
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test' ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test'
``` ```
## Basic tests
Run basic automated tests on the UTM macOS VM (never on the host machine):
```bash
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmuxterm DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmuxterm DEV.app" -print -quit) && open "$APP" && for i in {1..20}; do [ -S /tmp/cmuxterm.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py'
```
## Release ## Release
Tagging a version triggers the GitHub Actions release workflow and uploads the notarized zip. Tagging a version triggers the GitHub Actions release workflow and uploads the notarized zip.

546
CLI/cmuxterm.swift Normal file
View file

@ -0,0 +1,546 @@
import Foundation
import Darwin
struct CLIError: Error, CustomStringConvertible {
let message: String
var description: String { message }
}
struct TabInfo {
let index: Int
let id: String
let title: String
let selected: Bool
}
struct PanelInfo {
let index: Int
let id: String
let focused: Bool
}
struct NotificationInfo {
let id: String
let tabId: String
let panelId: String?
let isRead: Bool
let title: String
let subtitle: String
let body: String
}
final class SocketClient {
private let path: String
private var socketFD: Int32 = -1
init(path: String) {
self.path = path
}
func connect() throws {
if socketFD >= 0 { return }
socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
if socketFD < 0 {
throw CLIError(message: "Failed to create socket")
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
}
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
if result != 0 {
Darwin.close(socketFD)
socketFD = -1
throw CLIError(message: "Failed to connect to socket at \(path)")
}
}
func close() {
if socketFD >= 0 {
Darwin.close(socketFD)
socketFD = -1
}
}
func send(command: String) throws -> String {
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
let payload = command + "\n"
try payload.withCString { ptr in
let sent = Darwin.write(socketFD, ptr, strlen(ptr))
if sent < 0 {
throw CLIError(message: "Failed to write to socket")
}
}
var data = Data()
var sawNewline = false
let start = Date()
while true {
var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0)
let ready = poll(&pollFD, 1, 100)
if ready < 0 {
throw CLIError(message: "Socket read error")
}
if ready == 0 {
if sawNewline {
break
}
if Date().timeIntervalSince(start) > 5.0 {
throw CLIError(message: "Command timed out")
}
continue
}
var buffer = [UInt8](repeating: 0, count: 8192)
let count = Darwin.read(socketFD, &buffer, buffer.count)
if count <= 0 {
break
}
data.append(buffer, count: count)
if data.contains(UInt8(0x0A)) {
sawNewline = true
}
}
guard var response = String(data: data, encoding: .utf8) else {
throw CLIError(message: "Invalid UTF-8 response")
}
if response.hasSuffix("\n") {
response.removeLast()
}
return response
}
}
struct CMUXCLI {
let args: [String]
func run() throws {
var socketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmuxterm.sock"
var jsonOutput = false
var index = 1
while index < args.count {
let arg = args[index]
if arg == "--socket" {
guard index + 1 < args.count else {
throw CLIError(message: "--socket requires a path")
}
socketPath = args[index + 1]
index += 2
continue
}
if arg == "--json" {
jsonOutput = true
index += 1
continue
}
if arg == "-h" || arg == "--help" {
print(usage())
return
}
break
}
guard index < args.count else {
print(usage())
throw CLIError(message: "Missing command")
}
let command = args[index]
let commandArgs = Array(args[(index + 1)...])
let client = SocketClient(path: socketPath)
try client.connect()
defer { client.close() }
switch command {
case "ping":
let response = try client.send(command: "ping")
print(response)
case "list-tabs":
let response = try client.send(command: "list_tabs")
if jsonOutput {
let tabs = parseTabs(response)
let payload = tabs.map { [
"index": $0.index,
"id": $0.id,
"title": $0.title,
"selected": $0.selected
] }
print(jsonString(payload))
} else {
print(response)
}
case "new-tab":
let response = try client.send(command: "new_tab")
print(response)
case "new-split":
guard let direction = commandArgs.first else {
throw CLIError(message: "new-split requires a direction")
}
let response = try client.send(command: "new_split \(direction)")
print(response)
case "list-panels":
let (tabArg, _) = parseOption(commandArgs, name: "--tab")
let response = try client.send(command: "list_surfaces \(tabArg ?? "")".trimmingCharacters(in: .whitespaces))
if jsonOutput {
let panels = parsePanels(response)
let payload = panels.map { [
"index": $0.index,
"id": $0.id,
"focused": $0.focused
] }
print(jsonString(payload))
} else {
print(response)
}
case "focus-panel":
guard let panel = optionValue(commandArgs, name: "--panel") else {
throw CLIError(message: "focus-panel requires --panel")
}
let response = try client.send(command: "focus_surface \(panel)")
print(response)
case "close-tab":
guard let tab = optionValue(commandArgs, name: "--tab") else {
throw CLIError(message: "close-tab requires --tab")
}
let tabId = try resolveTabId(tab, client: client)
let response = try client.send(command: "close_tab \(tabId)")
print(response)
case "select-tab":
guard let tab = optionValue(commandArgs, name: "--tab") else {
throw CLIError(message: "select-tab requires --tab")
}
let response = try client.send(command: "select_tab \(tab)")
print(response)
case "current-tab":
let response = try client.send(command: "current_tab")
if jsonOutput {
print(jsonString(["tab_id": response]))
} else {
print(response)
}
case "send":
let text = commandArgs.joined(separator: " ")
guard !text.isEmpty else { throw CLIError(message: "send requires text") }
let escaped = escapeText(text)
let response = try client.send(command: "send \(escaped)")
print(response)
case "send-key":
guard let key = commandArgs.first else { throw CLIError(message: "send-key requires a key") }
let response = try client.send(command: "send_key \(key)")
print(response)
case "send-panel":
guard let panel = optionValue(commandArgs, name: "--panel") else {
throw CLIError(message: "send-panel requires --panel")
}
let text = remainingArgs(commandArgs, removing: ["--panel", panel]).joined(separator: " ")
guard !text.isEmpty else { throw CLIError(message: "send-panel requires text") }
let escaped = escapeText(text)
let response = try client.send(command: "send_surface \(panel) \(escaped)")
print(response)
case "send-key-panel":
guard let panel = optionValue(commandArgs, name: "--panel") else {
throw CLIError(message: "send-key-panel requires --panel")
}
let key = remainingArgs(commandArgs, removing: ["--panel", panel]).first ?? ""
guard !key.isEmpty else { throw CLIError(message: "send-key-panel requires a key") }
let response = try client.send(command: "send_key_surface \(panel) \(key)")
print(response)
case "notify":
let title = optionValue(commandArgs, name: "--title") ?? "Notification"
let subtitle = optionValue(commandArgs, name: "--subtitle") ?? ""
let body = optionValue(commandArgs, name: "--body") ?? ""
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
let panelArg = optionValue(commandArgs, name: "--panel") ?? ProcessInfo.processInfo.environment["CMUX_PANEL_ID"]
let targetTab = try resolveTabId(tabArg, client: client)
let targetPanel = try resolvePanelId(panelArg, tabId: targetTab, client: client)
let payload = "\(title)|\(subtitle)|\(body)"
let response = try client.send(command: "notify_target \(targetTab) \(targetPanel) \(payload)")
print(response)
case "list-notifications":
let response = try client.send(command: "list_notifications")
if jsonOutput {
let notifications = parseNotifications(response)
let payload = notifications.map { item in
var dict: [String: Any] = [
"id": item.id,
"tab_id": item.tabId,
"is_read": item.isRead,
"title": item.title,
"subtitle": item.subtitle,
"body": item.body
]
dict["panel_id"] = item.panelId ?? NSNull()
return dict
}
print(jsonString(payload))
} else {
print(response)
}
case "clear-notifications":
let response = try client.send(command: "clear_notifications")
print(response)
case "set-app-focus":
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
let response = try client.send(command: "set_app_focus \(value)")
print(response)
case "simulate-app-active":
let response = try client.send(command: "simulate_app_active")
print(response)
case "help":
print(usage())
default:
print(usage())
throw CLIError(message: "Unknown command: \(command)")
}
}
private func parseTabs(_ response: String) -> [TabInfo] {
guard response != "No tabs" else { return [] }
return response
.split(separator: "\n")
.compactMap { line in
let raw = String(line)
let selected = raw.hasPrefix("*")
let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* "))
let parts = cleaned.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
guard parts.count >= 2 else { return nil }
let indexText = parts[0].replacingOccurrences(of: ":", with: "")
guard let index = Int(indexText) else { return nil }
let id = String(parts[1])
let title = parts.count > 2 ? String(parts[2]) : ""
return TabInfo(index: index, id: id, title: title, selected: selected)
}
}
private func parsePanels(_ response: String) -> [PanelInfo] {
guard response != "No surfaces" else { return [] }
return response
.split(separator: "\n")
.compactMap { line in
let raw = String(line)
let focused = raw.hasPrefix("*")
let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* "))
let parts = cleaned.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
guard parts.count >= 2 else { return nil }
let indexText = parts[0].replacingOccurrences(of: ":", with: "")
guard let index = Int(indexText) else { return nil }
let id = String(parts[1])
return PanelInfo(index: index, id: id, focused: focused)
}
}
private func parseNotifications(_ response: String) -> [NotificationInfo] {
guard response != "No notifications" else { return [] }
return response
.split(separator: "\n")
.compactMap { line in
let raw = String(line)
let parts = raw.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2 else { return nil }
let payload = parts[1].split(separator: "|", maxSplits: 6, omittingEmptySubsequences: false)
guard payload.count >= 7 else { return nil }
let notifId = String(payload[0])
let tabId = String(payload[1])
let panelRaw = String(payload[2])
let panelId = panelRaw == "none" ? nil : panelRaw
let readText = String(payload[3])
let title = String(payload[4])
let subtitle = String(payload[5])
let body = String(payload[6])
return NotificationInfo(
id: notifId,
tabId: tabId,
panelId: panelId,
isRead: readText == "read",
title: title,
subtitle: subtitle,
body: body
)
}
}
private func resolveTabId(_ raw: String?, client: SocketClient) throws -> String {
if let raw, isUUID(raw) {
return raw
}
if let raw, let index = Int(raw) {
let response = try client.send(command: "list_tabs")
let tabs = parseTabs(response)
if let match = tabs.first(where: { $0.index == index }) {
return match.id
}
throw CLIError(message: "Tab index not found")
}
let response = try client.send(command: "current_tab")
if response.hasPrefix("ERROR") {
throw CLIError(message: response)
}
return response
}
private func resolvePanelId(_ raw: String?, tabId: String, client: SocketClient) throws -> String {
if let raw, isUUID(raw) {
return raw
}
let response = try client.send(command: "list_surfaces \(tabId)")
if response.hasPrefix("ERROR") {
throw CLIError(message: response)
}
let panels = parsePanels(response)
if let raw, let index = Int(raw) {
if let match = panels.first(where: { $0.index == index }) {
return match.id
}
throw CLIError(message: "Panel index not found")
}
if let focused = panels.first(where: { $0.focused }) {
return focused.id
}
throw CLIError(message: "Unable to resolve panel ID")
}
private func parseOption(_ args: [String], name: String) -> (String?, [String]) {
var remaining: [String] = []
var value: String?
var skipNext = false
for (idx, arg) in args.enumerated() {
if skipNext {
skipNext = false
continue
}
if arg == name, idx + 1 < args.count {
value = args[idx + 1]
skipNext = true
continue
}
remaining.append(arg)
}
return (value, remaining)
}
private func optionValue(_ args: [String], name: String) -> String? {
guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil }
return args[index + 1]
}
private func remainingArgs(_ args: [String], removing tokens: [String]) -> [String] {
var remaining = args
for token in tokens {
if let index = remaining.firstIndex(of: token) {
remaining.remove(at: index)
}
}
return remaining
}
private func escapeText(_ text: String) -> String {
return text
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\t", with: "\\t")
}
private func isUUID(_ value: String) -> Bool {
return UUID(uuidString: value) != nil
}
private func jsonString(_ object: Any) -> String {
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
let output = String(data: data, encoding: .utf8) else {
return "{}"
}
return output
}
private func usage() -> String {
return """
cmuxterm - control cmuxterm via Unix socket
Usage:
cmuxterm [--socket PATH] [--json] <command> [options]
Commands:
ping
list-tabs
new-tab
new-split <left|right|up|down>
list-panels [--tab <id|index>]
focus-panel --panel <id|index>
close-tab --tab <id|index>
select-tab --tab <id|index>
current-tab
send <text>
send-key <key>
send-panel --panel <id|index> <text>
send-key-panel --panel <id|index> <key>
notify --title <text> [--subtitle <text>] [--body <text>] [--tab <id|index>] [--panel <id|index>]
list-notifications
clear-notifications
set-app-focus <active|inactive|clear>
simulate-app-active
help
Environment:
CMUX_TAB_ID, CMUX_PANEL_ID, CMUX_SOCKET_PATH
"""
}
}
@main
struct CMUXTermMain {
static func main() {
let cli = CMUXCLI(args: CommandLine.arguments)
do {
try cli.run()
} catch {
FileHandle.standardError.write(Data("Error: \(error)\n".utf8))
exit(1)
}
}
}

View file

@ -14,6 +14,7 @@
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; };
A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; };
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; };
A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; };
@ -36,7 +37,10 @@
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; };
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
B9000002A1B2C3D4E5F60719 /* cmuxterm.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */; };
B900000BA1B2C3D4E5F60719 /* cmuxterm in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmuxterm */; };
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; };
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -52,6 +56,17 @@
name = "Embed Frameworks"; name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B900000AA1B2C3D4E5F60719 /* Copy CLI */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "bin";
dstSubfolderSpec = 7;
files = (
B900000BA1B2C3D4E5F60719 /* cmuxterm in Copy CLI */,
);
name = "Copy CLI";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -62,6 +77,13 @@
remoteGlobalIDString = A5001050 /* GhosttyTabs */; remoteGlobalIDString = A5001050 /* GhosttyTabs */;
remoteInfo = GhosttyTabs; remoteInfo = GhosttyTabs;
}; };
B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5001070 /* Project object */;
proxyType = 1;
remoteGlobalIDString = B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */;
remoteInfo = "cmuxterm-cli";
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -76,6 +98,7 @@
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
A5001225 /* SocketControlSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlSettings.swift; sourceTree = "<group>"; };
A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.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>"; }; 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>"; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; };
@ -99,7 +122,10 @@
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "terminfo/78/xterm-ghostty"; sourceTree = "<group>"; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxterm.swift; sourceTree = "<group>"; };
B9000004A1B2C3D4E5F60719 /* cmuxterm */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmuxterm; sourceTree = BUILT_PRODUCTS_DIR; };
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -119,6 +145,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B900000CA1B2C3D4E5F60719 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
@ -140,11 +173,32 @@
}; };
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
A5001300A1B2C3D4E5F60719 /* Copy Ghostty Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ghostty\"\nSRC=\"${SRCROOT}/ghostty/zig-out/share/ghostty\"\nFALLBACK=\"${SRCROOT}/Resources/ghostty\"\nif [ -d \"$SRC\" ]; then\n mkdir -p \"$DEST\"\n rsync -a --delete \"$SRC/\" \"$DEST/\"\nelif [ -d \"$FALLBACK\" ]; then\n mkdir -p \"$DEST\"\n rsync -a \"$FALLBACK/\" \"$DEST/\"\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
A5001040 = { A5001040 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A5001041 /* Sources */, A5001041 /* Sources */,
B9000003A1B2C3D4E5F60719 /* CLI */,
087C454FFF74443AB06942C3 /* Resources */, 087C454FFF74443AB06942C3 /* Resources */,
A5001101 /* Assets.xcassets */, A5001101 /* Assets.xcassets */,
A5001016 /* GhosttyKit.xcframework */, A5001016 /* GhosttyKit.xcframework */,
@ -164,6 +218,7 @@
A5001014 /* GhosttyConfig.swift */, A5001014 /* GhosttyConfig.swift */,
A5001015 /* GhosttyTerminalView.swift */, A5001015 /* GhosttyTerminalView.swift */,
A5001019 /* TerminalController.swift */, A5001019 /* TerminalController.swift */,
A5001225 /* SocketControlSettings.swift */,
A5001090 /* AppDelegate.swift */, A5001090 /* AppDelegate.swift */,
A5001091 /* NotificationsPage.swift */, A5001091 /* NotificationsPage.swift */,
A5001092 /* TerminalNotificationStore.swift */, A5001092 /* TerminalNotificationStore.swift */,
@ -188,6 +243,14 @@
path = Sources; path = Sources;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B9000003A1B2C3D4E5F60719 /* CLI */ = {
isa = PBXGroup;
children = (
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */,
);
path = CLI;
sourceTree = "<group>";
};
087C454FFF74443AB06942C3 /* Resources */ = { 087C454FFF74443AB06942C3 /* Resources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -200,6 +263,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A5001000 /* cmuxterm.app */, A5001000 /* cmuxterm.app */,
B9000004A1B2C3D4E5F60719 /* cmuxterm */,
7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */, 7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */,
); );
name = Products; name = Products;
@ -208,6 +272,7 @@
3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = { 3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */,
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
); );
@ -223,12 +288,15 @@
buildPhases = ( buildPhases = (
A5001051 /* Sources */, A5001051 /* Sources */,
A5001030 /* Frameworks */, A5001030 /* Frameworks */,
A5001300A1B2C3D4E5F60719 /* Copy Ghostty Resources */,
A5001102 /* Resources */, A5001102 /* Resources */,
A5001020 /* Embed Frameworks */, A5001020 /* Embed Frameworks */,
B900000AA1B2C3D4E5F60719 /* Copy CLI */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */,
); );
packageProductDependencies = ( packageProductDependencies = (
A5001231 /* Sparkle */, A5001231 /* Sparkle */,
@ -238,6 +306,22 @@
productReference = A5001000 /* cmuxterm.app */; productReference = A5001000 /* cmuxterm.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */ = {
isa = PBXNativeTarget;
buildConfigurationList = B9000007A1B2C3D4E5F60719 /* Build configuration list for PBXNativeTarget "cmuxterm-cli" */;
buildPhases = (
B9000006A1B2C3D4E5F60719 /* Sources */,
B900000CA1B2C3D4E5F60719 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "cmuxterm-cli";
productName = cmuxterm;
productReference = B9000004A1B2C3D4E5F60719 /* cmuxterm */;
productType = "com.apple.product-type.tool";
};
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */ = { CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */; buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */;
@ -283,6 +367,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
A5001050 /* GhosttyTabs */, A5001050 /* GhosttyTabs */,
B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */,
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */, CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */,
); );
}; };
@ -299,6 +384,7 @@
A5001004 /* GhosttyConfig.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */,
A5001005 /* GhosttyTerminalView.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */, A5001007 /* TerminalController.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */,
A5001093 /* AppDelegate.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */,
A5001094 /* NotificationsPage.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */,
A5001095 /* TerminalNotificationStore.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */,
@ -326,11 +412,20 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */,
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B9000006A1B2C3D4E5F60719 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B9000002A1B2C3D4E5F60719 /* cmuxterm.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@ -339,6 +434,11 @@
target = A5001050 /* GhosttyTabs */; target = A5001050 /* GhosttyTabs */;
targetProxy = 738BF3D3196765B250928A93 /* PBXContainerItemProxy */; targetProxy = 738BF3D3196765B250928A93 /* PBXContainerItemProxy */;
}; };
B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */;
targetProxy = B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@ -494,6 +594,30 @@
}; };
name = Release; name = Release;
}; };
B9000008A1B2C3D4E5F60719 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_NAME = cmuxterm;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
B9000009A1B2C3D4E5F60719 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_NAME = cmuxterm;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
C117776A77E71D1432F570D7 /* Debug */ = { C117776A77E71D1432F570D7 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@ -567,6 +691,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
B9000007A1B2C3D4E5F60719 /* Build configuration list for PBXNativeTarget "cmuxterm-cli" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B9000008A1B2C3D4E5F60719 /* Debug */,
B9000009A1B2C3D4E5F60719 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A5001071 /* Build configuration list for PBXProject "GhosttyTabs" */ = { A5001071 /* Build configuration list for PBXProject "GhosttyTabs" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View file

@ -0,0 +1,112 @@
import XCTest
import Foundation
final class AutomationSocketUITests: XCTestCase {
private var socketPath = ""
private let defaultsDomain = "com.cmuxterm.app.debug"
private let modeKey = "socketControlMode"
private let legacyKey = "socketControlEnabled"
override func setUp() {
super.setUp()
continueAfterFailure = false
socketPath = "/tmp/cmuxterm-debug-\(UUID().uuidString).sock"
resetSocketDefaults()
removeSocketFile()
}
func testSocketToggleDisablesAndEnables() {
let app = XCUIApplication()
app.launchArguments += ["-\(modeKey)", "notifications"]
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launch()
app.activate()
guard let resolvedPath = resolveSocketPath(timeout: 5.0) else {
XCTFail("Expected control socket to exist")
return
}
socketPath = resolvedPath
XCTAssertTrue(waitForSocket(exists: true, timeout: 2.0))
app.terminate()
}
func testSocketDisabledWhenSettingOff() {
let app = XCUIApplication()
app.launchArguments += ["-\(modeKey)", "off"]
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launch()
app.activate()
XCTAssertTrue(waitForSocket(exists: false, timeout: 3.0))
app.terminate()
}
private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: socketPath) == exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return FileManager.default.fileExists(atPath: socketPath) == exists
}
private func resolveSocketPath(timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: socketPath) {
return socketPath
}
if let found = findSocketInTmp() {
return found
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if FileManager.default.fileExists(atPath: socketPath) {
return socketPath
}
return findSocketInTmp()
}
private func findSocketInTmp() -> String? {
let tmpPath = "/tmp"
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else {
return nil
}
let matches = entries.filter { $0.hasPrefix("cmuxterm") && $0.hasSuffix(".sock") }
if let debug = matches.first(where: { $0.contains("debug") }) {
return (tmpPath as NSString).appendingPathComponent(debug)
}
if let first = matches.first {
return (tmpPath as NSString).appendingPathComponent(first)
}
return nil
}
private func resetSocketDefaults() {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
process.arguments = ["delete", defaultsDomain, modeKey]
do {
try process.run()
process.waitUntilExit()
} catch {
return
}
let legacy = Process()
legacy.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
legacy.arguments = ["delete", defaultsDomain, legacyKey]
do {
try legacy.run()
legacy.waitUntilExit()
} catch {
return
}
}
private func removeSocketFile() {
try? FileManager.default.removeItem(atPath: socketPath)
}
}

View file

@ -12,7 +12,6 @@
- **Vertical tabs** — See all your terminals at a glance in a sidebar - **Vertical tabs** — See all your terminals at a glance in a sidebar
- **Notification panel** — Tabs flash when AI agents (Claude Code, Codex) need your attention - **Notification panel** — Tabs flash when AI agents (Claude Code, Codex) need your attention
- **Built on libghostty** — Native macOS performance with Ghostty's GPU-accelerated rendering - **Built on libghostty** — Native macOS performance with Ghostty's GPU-accelerated rendering
- **Auto-updates** — Stay current with Sparkle-powered updates
## Why cmuxterm? ## Why cmuxterm?

View file

@ -43,10 +43,18 @@ struct GhosttyConfig {
static func load() -> GhosttyConfig { static func load() -> GhosttyConfig {
var config = GhosttyConfig() var config = GhosttyConfig()
// Load user config // Match Ghostty's default load order on macOS.
let configPath = NSString(string: "~/Library/Application Support/com.mitchellh.ghostty/config").expandingTildeInPath let configPaths = [
if let contents = try? String(contentsOfFile: configPath, encoding: .utf8) { "~/.config/ghostty/config",
config.parse(contents) "~/.config/ghostty/config.ghostty",
"~/Library/Application Support/com.mitchellh.ghostty/config",
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
].map { NSString(string: $0).expandingTildeInPath }
for path in configPaths {
if let contents = readConfigFile(at: path) {
config.parse(contents)
}
} }
// Load theme if specified // Load theme if specified
@ -137,11 +145,15 @@ struct GhosttyConfig {
} }
mutating func loadTheme(_ name: String) { mutating func loadTheme(_ name: String) {
// Try to load from Ghostty app resources let bundleThemePath = Bundle.main.resourceURL?
.appendingPathComponent("ghostty/themes/\(name)")
.path
let themePaths = [ let themePaths = [
bundleThemePath,
"/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(name)", "/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(name)",
NSString(string: "~/.config/ghostty/themes/\(name)").expandingTildeInPath NSString(string: "~/.config/ghostty/themes/\(name)").expandingTildeInPath,
] ].compactMap { $0 }
for path in themePaths { for path in themePaths {
if let contents = try? String(contentsOfFile: path, encoding: .utf8) { if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
@ -150,6 +162,22 @@ struct GhosttyConfig {
} }
} }
} }
private static func readConfigFile(at path: String) -> String? {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: path) else { return nil }
if let attributes = try? fileManager.attributesOfItem(atPath: path) {
if let type = attributes[.type] as? FileAttributeType, type != .typeRegular {
return nil
}
if let size = attributes[.size] as? NSNumber, size.intValue == 0 {
return nil
}
}
return try? String(contentsOfFile: path, encoding: .utf8)
}
} }
extension NSColor { extension NSColor {

View file

@ -3,6 +3,7 @@ import SwiftUI
import AppKit import AppKit
import Metal import Metal
import QuartzCore import QuartzCore
import Darwin
private enum GhosttyPasteboardHelper { private enum GhosttyPasteboardHelper {
private static let selectionPasteboard = NSPasteboard( private static let selectionPasteboard = NSPasteboard(
@ -353,6 +354,7 @@ class GhosttyApp {
tabId: tabId, tabId: tabId,
surfaceId: surfaceId, surfaceId: surfaceId,
title: command, title: command,
subtitle: "",
body: body body: body
) )
} }
@ -509,6 +511,7 @@ class GhosttyApp {
tabId: tabId, tabId: tabId,
surfaceId: surfaceId, surfaceId: surfaceId,
title: command, title: command,
subtitle: "",
body: body body: body
) )
} }
@ -637,8 +640,63 @@ class TerminalSurface: Identifiable {
surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque() surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque()
surfaceConfig.scale_factor = scale surfaceConfig.scale_factor = scale
surfaceConfig.context = surfaceContext surfaceConfig.context = surfaceContext
var envVars: [ghostty_env_var_s] = []
var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = []
defer {
for (key, value) in envStorage {
free(key)
free(value)
}
}
surface = ghostty_surface_new(app, &surfaceConfig) var env: [String: String] = [:]
if surfaceConfig.env_var_count > 0, let existingEnv = surfaceConfig.env_vars {
let count = Int(surfaceConfig.env_var_count)
if count > 0 {
for i in 0..<count {
let item = existingEnv[i]
if let key = String(cString: item.key, encoding: .utf8),
let value = String(cString: item.value, encoding: .utf8) {
env[key] = value
}
}
}
}
env["CMUX_PANEL_ID"] = id.uuidString
env["CMUX_TAB_ID"] = tabId.uuidString
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path {
let currentPath = env["PATH"]
?? ProcessInfo.processInfo.environment["PATH"]
?? ""
if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) {
let separator = currentPath.isEmpty ? "" : ":"
env["PATH"] = "\(cliBinPath)\(separator)\(currentPath)"
}
}
if !env.isEmpty {
envVars.reserveCapacity(env.count)
envStorage.reserveCapacity(env.count)
for (key, value) in env {
guard let keyPtr = strdup(key), let valuePtr = strdup(value) else { continue }
envStorage.append((keyPtr, valuePtr))
envVars.append(ghostty_env_var_s(key: keyPtr, value: valuePtr))
}
}
if !envVars.isEmpty {
let envVarsCount = envVars.count
envVars.withUnsafeMutableBufferPointer { buffer in
surfaceConfig.env_vars = buffer.baseAddress
surfaceConfig.env_var_count = envVarsCount
surface = ghostty_surface_new(app, &surfaceConfig)
}
} else {
surface = ghostty_surface_new(app, &surfaceConfig)
}
if surface == nil { if surface == nil {
print("Failed to create ghostty surface") print("Failed to create ghostty surface")

View file

@ -0,0 +1,95 @@
import Foundation
enum SocketControlMode: String, CaseIterable, Identifiable {
case off
case notifications
case full
var id: String { rawValue }
var displayName: String {
switch self {
case .off:
return "Off"
case .notifications:
return "Notifications only"
case .full:
return "Full control"
}
}
var description: String {
switch self {
case .off:
return "Disable the local control socket."
case .notifications:
return "Allow only notification commands over the local socket."
case .full:
return "Allow all socket commands, including tab and input control."
}
}
}
struct SocketControlSettings {
static let appStorageKey = "socketControlMode"
static let legacyEnabledKey = "socketControlEnabled"
static var defaultMode: SocketControlMode {
#if DEBUG
return .full
#else
return .notifications
#endif
}
static func socketPath() -> String {
if let override = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"], !override.isEmpty {
return override
}
#if DEBUG
return "/tmp/cmuxterm-debug.sock"
#else
return "/tmp/cmuxterm.sock"
#endif
}
static func envOverrideEnabled() -> Bool? {
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
return nil
}
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return nil
}
}
static func envOverrideMode() -> SocketControlMode? {
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
return nil
}
return SocketControlMode(rawValue: raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
}
static func effectiveMode(userMode: SocketControlMode) -> SocketControlMode {
if let overrideEnabled = envOverrideEnabled() {
if !overrideEnabled {
return .off
}
if let overrideMode = envOverrideMode() {
return overrideMode
}
return userMode == .off ? .notifications : userMode
}
if let overrideMode = envOverrideMode() {
return overrideMode
}
return userMode
}
}

View file

@ -45,8 +45,8 @@ class Tab: Identifiable, ObservableObject {
if isSelectedTab { if isSelectedTab {
focusedSurface?.applyWindowBackgroundIfActive() focusedSurface?.applyWindowBackgroundIfActive()
} }
let isAppFocused = AppFocusState.isAppFocused() let isAppActive = AppFocusState.isAppActive()
guard isSelectedTab && isAppFocused else { return } guard isSelectedTab && isAppActive else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return } guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) { if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) {
triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false) triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false)
@ -465,7 +465,7 @@ class TabManager: ObservableObject {
let shouldSuppressFlash = suppressFocusFlash let shouldSuppressFlash = suppressFocusFlash
suppressFocusFlash = false suppressFocusFlash = false
guard !shouldSuppressFlash else { return } guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppFocused() else { return } guard AppFocusState.isAppActive() else { return }
guard let surfaceId = focusedSurfaceId(for: tabId) else { return } guard let surfaceId = focusedSurfaceId(for: tabId) else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return } guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }

View file

@ -6,16 +6,28 @@ import Foundation
class TerminalController { class TerminalController {
static let shared = TerminalController() static let shared = TerminalController()
private let socketPath = "/tmp/cmux.sock" private var socketPath = "/tmp/cmuxterm.sock"
private var serverSocket: Int32 = -1 private var serverSocket: Int32 = -1
private var isRunning = false private var isRunning = false
private var clientHandlers: [Int32: Thread] = [:] private var clientHandlers: [Int32: Thread] = [:]
private weak var tabManager: TabManager? private weak var tabManager: TabManager?
private var accessMode: SocketControlMode = .full
private init() {} private init() {}
func start(tabManager: TabManager) { func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
self.tabManager = tabManager self.tabManager = tabManager
self.accessMode = accessMode
if isRunning {
if self.socketPath == socketPath {
self.accessMode = accessMode
return
}
stop()
}
self.socketPath = socketPath
// Remove existing socket file // Remove existing socket file
unlink(socketPath) unlink(socketPath)
@ -133,6 +145,9 @@ class TerminalController {
let cmd = parts[0].lowercased() let cmd = parts[0].lowercased()
let args = parts.count > 1 ? parts[1] : "" let args = parts.count > 1 ? parts[1] : ""
if !isCommandAllowed(cmd) {
return "ERROR: Command disabled by socket access mode"
}
switch cmd { switch cmd {
case "ping": case "ping":
@ -180,6 +195,9 @@ class TerminalController {
case "notify_surface": case "notify_surface":
return notifySurface(args) return notifySurface(args)
case "notify_target":
return notifyTarget(args)
case "list_notifications": case "list_notifications":
return listNotifications() return listNotifications()
@ -227,8 +245,9 @@ class TerminalController {
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape) send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
send_surface <id|idx> <text> - Send text to a surface in current tab send_surface <id|idx> <text> - Send text to a surface in current tab
send_key_surface <id|idx> <key> - Send special key to a surface in current tab send_key_surface <id|idx> <key> - Send special key to a surface in current tab
notify <title>|<body> - Create a notification for the focused surface notify <title>|<subtitle>|<body> - Create a notification for the focused surface
notify_surface <id|idx> <title>|<body> - Create a notification for a surface notify_surface <id|idx> <title>|<subtitle>|<body> - Create a notification for a surface
notify_target <tabId> <panelId> <title>|<subtitle>|<body> - Notify a specific panel
list_notifications - List all notifications list_notifications - List all notifications
clear_notifications - Clear all notifications clear_notifications - Clear all notifications
set_app_focus <active|inactive|clear> - Override app focus state set_app_focus <active|inactive|clear> - Override app focus state
@ -246,6 +265,26 @@ class TerminalController {
return text return text
} }
private func isCommandAllowed(_ command: String) -> Bool {
switch accessMode {
case .full:
return true
case .notifications:
let allowed: Set<String> = [
"ping",
"help",
"notify",
"notify_surface",
"notify_target",
"list_notifications",
"clear_notifications"
]
return allowed.contains(command)
case .off:
return false
}
}
private func listTabs() -> String { private func listTabs() -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" } guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
@ -350,11 +389,12 @@ class TerminalController {
return return
} }
let surfaceId = tabManager.focusedSurfaceId(for: tabId) let surfaceId = tabManager.focusedSurfaceId(for: tabId)
let (title, body) = parseNotificationPayload(args) let (title, subtitle, body) = parseNotificationPayload(args)
TerminalNotificationStore.shared.addNotification( TerminalNotificationStore.shared.addNotification(
tabId: tabId, tabId: tabId,
surfaceId: surfaceId, surfaceId: surfaceId,
title: title, title: title,
subtitle: subtitle,
body: body body: body
) )
} }
@ -381,11 +421,47 @@ class TerminalController {
result = "ERROR: Surface not found" result = "ERROR: Surface not found"
return return
} }
let (title, body) = parseNotificationPayload(payload) let (title, subtitle, body) = parseNotificationPayload(payload)
TerminalNotificationStore.shared.addNotification( TerminalNotificationStore.shared.addNotification(
tabId: tabId, tabId: tabId,
surfaceId: surfaceId, surfaceId: surfaceId,
title: title, title: title,
subtitle: subtitle,
body: body
)
}
return result
}
private func notifyTarget(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Usage: notify_target <tabId> <panelId> <title>|<subtitle>|<body>" }
let parts = trimmed.split(separator: " ", maxSplits: 2).map(String.init)
guard parts.count >= 2 else { return "ERROR: Usage: notify_target <tabId> <panelId> <title>|<subtitle>|<body>" }
let tabArg = parts[0]
let panelArg = parts[1]
let payload = parts.count > 2 ? parts[2] : ""
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
result = "ERROR: Tab not found"
return
}
guard let panelId = UUID(uuidString: panelArg),
tab.surface(for: panelId) != nil else {
result = "ERROR: Panel not found"
return
}
let (title, subtitle, body) = parseNotificationPayload(payload)
TerminalNotificationStore.shared.addNotification(
tabId: tab.id,
surfaceId: panelId,
title: title,
subtitle: subtitle,
body: body body: body
) )
} }
@ -398,7 +474,7 @@ class TerminalController {
let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in
let surfaceText = notification.surfaceId?.uuidString ?? "none" let surfaceText = notification.surfaceId?.uuidString ?? "none"
let readText = notification.isRead ? "read" : "unread" let readText = notification.isRead ? "read" : "unread"
return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.body)" return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.subtitle)|\(notification.body)"
} }
result = lines.joined(separator: "\n") result = lines.joined(separator: "\n")
} }
@ -559,13 +635,16 @@ class TerminalController {
return nil return nil
} }
private func parseNotificationPayload(_ args: String) -> (String, String) { private func parseNotificationPayload(_ args: String) -> (String, String, String) {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return ("Notification", "") } guard !trimmed.isEmpty else { return ("Notification", "", "") }
let parts = trimmed.split(separator: "|", maxSplits: 1).map(String.init) let parts = trimmed.split(separator: "|", maxSplits: 2).map(String.init)
let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
let body = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" let subtitle = parts.count > 2 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
return (title.isEmpty ? "Notification" : title, body) let body = parts.count > 2
? parts[2].trimmingCharacters(in: .whitespacesAndNewlines)
: (parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : "")
return (title.isEmpty ? "Notification" : title, subtitle, body)
} }
private func closeTab(_ tabId: String) -> String { private func closeTab(_ tabId: String) -> String {

View file

@ -5,6 +5,13 @@ import UserNotifications
enum AppFocusState { enum AppFocusState {
static var overrideIsFocused: Bool? static var overrideIsFocused: Bool?
static func isAppActive() -> Bool {
if let overrideIsFocused {
return overrideIsFocused
}
return NSApp.isActive
}
static func isAppFocused() -> Bool { static func isAppFocused() -> Bool {
if let overrideIsFocused { if let overrideIsFocused {
return overrideIsFocused return overrideIsFocused
@ -18,6 +25,7 @@ struct TerminalNotification: Identifiable, Hashable {
let tabId: UUID let tabId: UUID
let surfaceId: UUID? let surfaceId: UUID?
let title: String let title: String
let subtitle: String
let body: String let body: String
let createdAt: Date let createdAt: Date
var isRead: Bool var isRead: Bool
@ -56,7 +64,7 @@ final class TerminalNotificationStore: ObservableObject {
return notifications.first(where: { $0.tabId == tabId }) return notifications.first(where: { $0.tabId == tabId })
} }
func addNotification(tabId: UUID, surfaceId: UUID?, title: String, body: String) { func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) {
clearNotifications(forTabId: tabId, surfaceId: surfaceId) clearNotifications(forTabId: tabId, surfaceId: surfaceId)
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
@ -73,6 +81,7 @@ final class TerminalNotificationStore: ObservableObject {
tabId: tabId, tabId: tabId,
surfaceId: surfaceId, surfaceId: surfaceId,
title: title, title: title,
subtitle: subtitle,
body: body, body: body,
createdAt: Date(), createdAt: Date(),
isRead: false isRead: false
@ -168,8 +177,8 @@ final class TerminalNotificationStore: ObservableObject {
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
?? "cmuxterm" ?? "cmuxterm"
content.title = appName content.title = notification.title.isEmpty ? appName : notification.title
content.subtitle = notification.title content.subtitle = notification.subtitle
content.body = notification.body content.body = notification.body
content.sound = UNNotificationSound.default content.sound = UNNotificationSound.default
content.categoryIdentifier = Self.categoryIdentifier content.categoryIdentifier = Self.categoryIdentifier

View file

@ -8,17 +8,25 @@ struct WindowAccessor: NSViewRepresentable {
Coordinator() Coordinator()
} }
func makeNSView(context: Context) -> NSView { func makeNSView(context: Context) -> WindowObservingView {
NSView() let view = WindowObservingView()
} view.onWindow = { window in
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async { [weak nsView] in
guard let window = nsView?.window else { return }
guard context.coordinator.lastWindow !== window else { return } guard context.coordinator.lastWindow !== window else { return }
context.coordinator.lastWindow = window context.coordinator.lastWindow = window
onWindow(window) onWindow(window)
} }
return view
}
func updateNSView(_ nsView: WindowObservingView, context: Context) {
nsView.onWindow = { window in
guard context.coordinator.lastWindow !== window else { return }
context.coordinator.lastWindow = window
onWindow(window)
}
if let window = nsView.window {
nsView.onWindow?(window)
}
} }
} }
@ -27,3 +35,21 @@ extension WindowAccessor {
weak var lastWindow: NSWindow? weak var lastWindow: NSWindow?
} }
} }
final class WindowObservingView: NSView {
var onWindow: ((NSWindow) -> Void)?
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
if let newWindow {
onWindow?(newWindow)
}
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let window {
onWindow?(window)
}
}
}

View file

@ -1,5 +1,6 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
import Darwin
@main @main
struct cmuxApp: App { struct cmuxApp: App {
@ -12,6 +13,7 @@ struct cmuxApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() { init() {
configureGhosttyEnvironment()
// Start the terminal controller for programmatic control // Start the terminal controller for programmatic control
// This runs after TabManager is created via @StateObject // This runs after TabManager is created via @StateObject
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
@ -22,6 +24,57 @@ struct cmuxApp: App {
} }
} }
private func configureGhosttyEnvironment() {
let fileManager = FileManager.default
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty")
var resolvedResourcesDir: String?
if getenv("GHOSTTY_RESOURCES_DIR") == nil {
if let bundledGhosttyURL,
fileManager.fileExists(atPath: bundledGhosttyURL.path),
fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) {
resolvedResourcesDir = bundledGhosttyURL.path
} else if fileManager.fileExists(atPath: ghosttyAppResources) {
resolvedResourcesDir = ghosttyAppResources
} else if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path) {
resolvedResourcesDir = bundledGhosttyURL.path
}
if let resolvedResourcesDir {
setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1)
}
}
if getenv("TERM") == nil {
setenv("TERM", "xterm-ghostty", 1)
}
if getenv("TERM_PROGRAM") == nil {
setenv("TERM_PROGRAM", "ghostty", 1)
}
if let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap({ String(cString: $0) }) {
let resourcesURL = URL(fileURLWithPath: resourcesDir)
let resourcesParent = resourcesURL.deletingLastPathComponent()
let dataDir = resourcesParent.path
let manDir = resourcesParent.appendingPathComponent("man").path
appendEnvPathIfMissing("XDG_DATA_DIRS", path: dataDir)
appendEnvPathIfMissing("MANPATH", path: manDir)
}
}
private func appendEnvPathIfMissing(_ key: String, path: String) {
if path.isEmpty { return }
let current = getenv(key).flatMap { String(cString: $0) } ?? ""
if current.split(separator: ":").contains(Substring(path)) {
return
}
let updated = current.isEmpty ? path : "\(current):\(path)"
setenv(key, updated, 1)
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView(updateViewModel: appDelegate.updateViewModel) ContentView(updateViewModel: appDelegate.updateViewModel)
@ -50,6 +103,7 @@ struct cmuxApp: App {
Settings { Settings {
SettingsRootView() SettingsRootView()
} }
.defaultSize(width: 460, height: 280)
.windowResizability(.contentMinSize) .windowResizability(.contentMinSize)
.commands { .commands {
CommandGroup(replacing: .appInfo) { CommandGroup(replacing: .appInfo) {
@ -327,6 +381,7 @@ struct SettingsView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(20) .padding(20)
.padding(.top, 4)
.frame(minWidth: 360, minHeight: 280) .frame(minWidth: 360, minHeight: 280)
} }
} }
@ -341,12 +396,21 @@ private struct SettingsRootView: View {
private func configureSettingsWindow(_ window: NSWindow) { private func configureSettingsWindow(_ window: NSWindow) {
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
window.title = ""
window.titleVisibility = .hidden window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = false
window.styleMask.remove(.fullSizeContentView)
window.styleMask.insert(.resizable) window.styleMask.insert(.resizable)
window.contentMinSize = NSSize(width: 360, height: 280) window.contentMinSize = NSSize(width: 360, height: 280)
if window.frame.width > 520 { if window.toolbar == nil {
window.setContentSize(NSSize(width: 460, height: max(280, window.contentView?.frame.height ?? 280))) let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.settings.toolbar"))
toolbar.displayMode = .iconOnly
toolbar.sizeMode = .regular
toolbar.allowsUserCustomization = false
toolbar.autosavesConfiguration = false
toolbar.showsBaselineSeparator = false
window.toolbar = toolbar
window.toolbarStyle = .unified
} }
let accessories = window.titlebarAccessoryViewControllers let accessories = window.titlebarAccessoryViewControllers

@ -1 +1 @@
Subproject commit ef19290456c4a2368f7e24527cb617e6581adb79 Subproject commit c8c28df2e5c39048358fa04197c4b4ebf9b3cd33

36
scripts/notify_probe.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
# Probe common desktop-notification escape sequences.
# NOTE: cmux suppresses notifications when the app + surface are focused,
# so switch to another app/window while this runs.
esc=$'\033'
bel=$'\007'
st="${esc}\\"
send_seq() {
local label="$1"
local seq="$2"
printf '\n[%s]\n' "$label"
printf '%b' "$seq"
}
sleep_between() {
# Ghostty rate limits notifications (~1/sec) and suppresses identical
# content within a short window, so keep spacing + unique content.
sleep 1.2
}
send_seq "OSC 9 (iTerm2) body-only, BEL terminator" "${esc}]9;cmux OSC 9 BEL $RANDOM${bel}"
sleep_between
send_seq "OSC 9 (iTerm2) body-only, ST terminator" "${esc}]9;cmux OSC 9 ST $RANDOM${st}"
sleep_between
send_seq "OSC 777 (rxvt) notify, BEL terminator" "${esc}]777;notify;cmux OSC 777 BEL $RANDOM;body ${RANDOM}${bel}"
sleep_between
send_seq "OSC 777 (rxvt) notify, ST terminator" "${esc}]777;notify;cmux OSC 777 ST $RANDOM;body ${RANDOM}${st}"
printf '\nDone.\n'

View file

@ -31,7 +31,9 @@ Usage:
import socket import socket
import select import select
import os import os
from typing import Optional, List, Tuple import time
import errno
from typing import Optional, List, Tuple, Union
class cmuxError(Exception): class cmuxError(Exception):
@ -39,10 +41,21 @@ class cmuxError(Exception):
pass pass
def _default_socket_path() -> str:
override = os.environ.get("CMUX_SOCKET_PATH")
if override:
return override
candidates = ["/tmp/cmuxterm-debug.sock", "/tmp/cmuxterm.sock"]
for path in candidates:
if os.path.exists(path):
return path
return candidates[0]
class cmux: class cmux:
"""Client for controlling cmux via Unix socket""" """Client for controlling cmux via Unix socket"""
DEFAULT_SOCKET_PATH = "/tmp/cmux.sock" DEFAULT_SOCKET_PATH = _default_socket_path()
def __init__(self, socket_path: str = None): def __init__(self, socket_path: str = None):
self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH
@ -54,19 +67,30 @@ class cmux:
if self._socket is not None: if self._socket is not None:
return return
if not os.path.exists(self.socket_path): start = time.time()
raise cmuxError( while not os.path.exists(self.socket_path):
f"Socket not found at {self.socket_path}. " if time.time() - start >= 2.0:
"Is cmux running?" raise cmuxError(
) f"Socket not found at {self.socket_path}. "
"Is cmux running?"
)
time.sleep(0.1)
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) last_error: Optional[socket.error] = None
try: while True:
self._socket.connect(self.socket_path) self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._socket.settimeout(5.0) try:
except socket.error as e: self._socket.connect(self.socket_path)
self._socket = None self._socket.settimeout(5.0)
raise cmuxError(f"Failed to connect: {e}") return
except socket.error as e:
last_error = e
self._socket.close()
self._socket = None
if e.errno in (errno.ECONNREFUSED, errno.ENOENT) and time.time() - start < 2.0:
time.sleep(0.1)
continue
raise cmuxError(f"Failed to connect: {e}")
def close(self) -> None: def close(self) -> None:
"""Close the connection""" """Close the connection"""
@ -91,20 +115,26 @@ class cmux:
self._socket.sendall((command + "\n").encode()) self._socket.sendall((command + "\n").encode())
data = self._recv_buffer data = self._recv_buffer
self._recv_buffer = "" self._recv_buffer = ""
saw_newline = "\n" in data
start = time.time()
while True: while True:
if "\n" not in data: if saw_newline:
chunk = self._socket.recv(8192) ready, _, _ = select.select([self._socket], [], [], 0.1)
if not chunk: if not ready:
break break
data += chunk.decode() try:
chunk = self._socket.recv(8192)
except socket.timeout:
if saw_newline:
break
if time.time() - start >= 5.0:
raise cmuxError("Command timed out")
continue continue
ready, _, _ = select.select([self._socket], [], [], 0.01)
if not ready:
break
chunk = self._socket.recv(8192)
if not chunk: if not chunk:
break break
data += chunk.decode() data += chunk.decode()
if "\n" in data:
saw_newline = True
if data.endswith("\n"): if data.endswith("\n"):
data = data[:-1] data = data[:-1]
return data return data
@ -159,13 +189,13 @@ class cmux:
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
def select_tab(self, tab: str | int) -> None: def select_tab(self, tab: Union[str, int]) -> None:
"""Select a tab by ID or index""" """Select a tab by ID or index"""
response = self._send_command(f"select_tab {tab}") response = self._send_command(f"select_tab {tab}")
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
def list_surfaces(self, tab: str | int | None = None) -> List[Tuple[int, str, bool]]: def list_surfaces(self, tab: Union[str, int, None] = None) -> List[Tuple[int, str, bool]]:
""" """
List surfaces for a tab. Returns list of (index, id, is_focused) tuples. List surfaces for a tab. Returns list of (index, id, is_focused) tuples.
If tab is None, uses the current tab. If tab is None, uses the current tab.
@ -187,7 +217,7 @@ class cmux:
surfaces.append((index, surface_id, selected)) surfaces.append((index, surface_id, selected))
return surfaces return surfaces
def focus_surface(self, surface: str | int) -> None: def focus_surface(self, surface: Union[str, int]) -> None:
"""Focus a surface by ID or index in the current tab.""" """Focus a surface by ID or index in the current tab."""
response = self._send_command(f"focus_surface {surface}") response = self._send_command(f"focus_surface {surface}")
if not response.startswith("OK"): if not response.startswith("OK"):
@ -216,7 +246,7 @@ class cmux:
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
def send_surface(self, surface: str | int, text: str) -> None: def send_surface(self, surface: Union[str, int], text: str) -> None:
"""Send text to a specific surface by ID or index in the current tab.""" """Send text to a specific surface by ID or index in the current tab."""
escaped = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") escaped = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
response = self._send_command(f"send_surface {surface} {escaped}") response = self._send_command(f"send_surface {surface} {escaped}")
@ -236,7 +266,7 @@ class cmux:
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
def send_key_surface(self, surface: str | int, key: str) -> None: def send_key_surface(self, surface: Union[str, int], key: str) -> None:
"""Send a special key to a specific surface by ID or index in the current tab.""" """Send a special key to a specific surface by ID or index in the current tab."""
response = self._send_command(f"send_key_surface {surface} {key}") response = self._send_command(f"send_key_surface {surface} {key}")
if not response.startswith("OK"): if not response.startswith("OK"):
@ -258,16 +288,22 @@ class cmux:
"""Get help text from server""" """Get help text from server"""
return self._send_command("help") return self._send_command("help")
def notify(self, title: str, body: str = "") -> None: def notify(self, title: str, subtitle: str = "", body: str = "") -> None:
"""Create a notification for the focused surface.""" """Create a notification for the focused surface."""
payload = f"{title}|{body}" if body else title if subtitle or body:
payload = f"{title}|{subtitle}|{body}"
else:
payload = title
response = self._send_command(f"notify {payload}") response = self._send_command(f"notify {payload}")
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
def notify_surface(self, surface: str | int, title: str, body: str = "") -> None: def notify_surface(self, surface: Union[str, int], title: str, subtitle: str = "", body: str = "") -> None:
"""Create a notification for a specific surface by ID or index.""" """Create a notification for a specific surface by ID or index."""
payload = f"{title}|{body}" if body else title if subtitle or body:
payload = f"{title}|{subtitle}|{body}"
else:
payload = title
response = self._send_command(f"notify_surface {surface} {payload}") response = self._send_command(f"notify_surface {surface} {payload}")
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
@ -275,7 +311,7 @@ class cmux:
def list_notifications(self) -> list[dict]: def list_notifications(self) -> list[dict]:
""" """
List notifications. List notifications.
Returns list of dicts with keys: id, tab_id, surface_id, is_read, title, body. Returns list of dicts with keys: id, tab_id, surface_id, is_read, title, subtitle, body.
""" """
response = self._send_command("list_notifications") response = self._send_command("list_notifications")
if response == "No notifications": if response == "No notifications":
@ -286,16 +322,17 @@ class cmux:
if not line.strip(): if not line.strip():
continue continue
_, payload = line.split(":", 1) _, payload = line.split(":", 1)
parts = payload.split("|", 5) parts = payload.split("|", 6)
if len(parts) < 6: if len(parts) < 7:
continue continue
notif_id, tab_id, surface_id, read_text, title, body = parts notif_id, tab_id, surface_id, read_text, title, subtitle, body = parts
items.append({ items.append({
"id": notif_id, "id": notif_id,
"tab_id": tab_id, "tab_id": tab_id,
"surface_id": None if surface_id == "none" else surface_id, "surface_id": None if surface_id == "none" else surface_id,
"is_read": read_text == "read", "is_read": read_text == "read",
"title": title, "title": title,
"subtitle": subtitle,
"body": body, "body": body,
}) })
return items return items
@ -306,7 +343,7 @@ class cmux:
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
def set_app_focus(self, active: bool | None) -> None: def set_app_focus(self, active: Union[bool, None]) -> None:
"""Override app focus state. Use None to clear override.""" """Override app focus state. Use None to clear override."""
if active is None: if active is None:
value = "clear" value = "clear"
@ -322,7 +359,7 @@ class cmux:
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
def focus_notification(self, tab: str | int, surface: str | int | None = None) -> None: def focus_notification(self, tab: Union[str, int], surface: Union[str, int, None] = None) -> None:
"""Focus tab/surface using the notification flow.""" """Focus tab/surface using the notification flow."""
if surface is None: if surface is None:
command = f"focus_notification {tab}" command = f"focus_notification {tab}"
@ -332,7 +369,7 @@ class cmux:
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) raise cmuxError(response)
def flash_count(self, surface: str | int) -> int: def flash_count(self, surface: Union[str, int]) -> int:
"""Get flash count for a surface by ID or index.""" """Get flash count for a surface by ID or index."""
response = self._send_command(f"flash_count {surface}") response = self._send_command(f"flash_count {surface}")
if response.startswith("OK "): if response.startswith("OK "):

View file

@ -13,6 +13,7 @@ import sys
import time import time
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional
# Add the directory containing cmux.py to the path # Add the directory containing cmux.py to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -35,7 +36,7 @@ def has_ctrl_enter_keybind(config_text: str) -> bool:
return False return False
def find_config_with_keybind() -> Path | None: def find_config_with_keybind() -> Optional[Path]:
home = Path.home() home = Path.home()
candidates = [ candidates = [
home / "Library/Application Support/com.mitchellh.ghostty/config.ghostty", home / "Library/Application Support/com.mitchellh.ghostty/config.ghostty",

View file

@ -65,15 +65,24 @@ def test_ctrl_c(client: cmux) -> TestResult:
# Start a long sleep # Start a long sleep
client.send("sleep 30\n") client.send("sleep 30\n")
time.sleep(0.3) time.sleep(0.8)
# Send Ctrl+C to interrupt # Send Ctrl+C to interrupt
client.send_ctrl_c() client.send_ctrl_c()
time.sleep(0.3) time.sleep(0.8)
# If Ctrl+C worked, shell should accept new command # If Ctrl+C worked, shell should accept new command
client.send(f"touch {marker}\n") for attempt in range(3):
time.sleep(0.5) client.send(f"touch {marker}\n")
for _ in range(10):
if marker.exists():
break
time.sleep(0.2)
if marker.exists():
break
# try another Ctrl+C in case the process swallowed the signal
client.send_ctrl_c()
time.sleep(0.6)
if marker.exists(): if marker.exists():
result.success("Ctrl+C interrupted sleep, shell responsive") result.success("Ctrl+C interrupted sleep, shell responsive")
@ -104,15 +113,18 @@ def test_ctrl_d(client: cmux) -> TestResult:
# Run cat (waits for input) # Run cat (waits for input)
client.send("cat\n") client.send("cat\n")
time.sleep(0.3) time.sleep(0.6)
# Send Ctrl+D (EOF) # Send Ctrl+D (EOF)
client.send_ctrl_d() client.send_ctrl_d()
time.sleep(0.3) time.sleep(0.4)
# If Ctrl+D worked, cat should exit and we can run another command # If Ctrl+D worked, cat should exit and we can run another command
client.send(f"touch {marker}\n") client.send(f"touch {marker}\n")
time.sleep(0.5) for _ in range(10):
if marker.exists():
break
time.sleep(0.2)
if marker.exists(): if marker.exists():
result.success("Ctrl+D sent EOF, cat exited") result.success("Ctrl+D sent EOF, cat exited")
@ -140,15 +152,18 @@ def test_ctrl_c_python(client: cmux) -> TestResult:
# Start Python that loops forever # Start Python that loops forever
client.send("python3 -c 'import time; [time.sleep(1) for _ in iter(int, 1)]'\n") client.send("python3 -c 'import time; [time.sleep(1) for _ in iter(int, 1)]'\n")
time.sleep(1.0) # Give Python time to start time.sleep(1.2) # Give Python time to start
# Send Ctrl+C # Send Ctrl+C
client.send_ctrl_c() client.send_ctrl_c()
time.sleep(0.5) time.sleep(0.6)
# If Ctrl+C worked, shell should accept new command # If Ctrl+C worked, shell should accept new command
client.send(f"touch {marker}\n") client.send(f"touch {marker}\n")
time.sleep(0.5) for _ in range(10):
if marker.exists():
break
time.sleep(0.2)
if marker.exists(): if marker.exists():
result.success("Ctrl+C interrupted Python process") result.success("Ctrl+C interrupted Python process")

View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
E2E: focusing a panel clears its notification and triggers a flash.
Note: This uses the socket focus command (no assistive access needed).
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def wait_for_notification(client: cmux, surface_id: str, is_read: bool, timeout: float = 2.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
items = client.list_notifications()
for item in items:
if item["surface_id"] == surface_id and item["is_read"] == is_read:
return True
time.sleep(0.05)
return False
def surface_id_for_index(client: cmux, index: int) -> str:
surfaces = client.list_surfaces()
for entry in surfaces:
if entry[0] == index:
return entry[1]
raise RuntimeError(f"Surface index {index} not found")
def ensure_two_surfaces(client: cmux) -> None:
surfaces = client.list_surfaces()
if len(surfaces) < 2:
client.new_split("right")
time.sleep(0.2)
def main() -> int:
try:
with cmux() as client:
client.set_app_focus(None)
ensure_two_surfaces(client)
client.focus_surface(0)
surface_id = surface_id_for_index(client, 1)
client.clear_notifications()
client.reset_flash_counts()
initial_flash = client.flash_count(1)
client.notify_surface(1, "Focus Test", "panel", "body")
if not wait_for_notification(client, surface_id, is_read=False, timeout=2.0):
print("FAIL: Notification did not appear as unread")
return 1
client.focus_surface(1)
client.send("x")
time.sleep(0.2)
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification did not become read after focus")
return 1
final_flash = client.flash_count(1)
if final_flash <= initial_flash:
print(f"FAIL: Flash count did not increment (before={initial_flash}, after={final_flash})")
return 1
print("PASS: Focus clears notification and flashes panel")
return 0
except (cmuxError, RuntimeError) as exc:
print(f"FAIL: {exc}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -12,6 +12,7 @@ Requirements:
import os import os
import sys import sys
import time import time
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -60,7 +61,7 @@ def focused_surface_index(client: cmux) -> int:
return focused[0] return focused[0]
def send_osc(client: cmux, sequence: str, surface: int | None = None) -> None: def send_osc(client: cmux, sequence: str, surface: Optional[int] = None) -> None:
"""Send an OSC sequence by printing it in the shell.""" """Send an OSC sequence by printing it in the shell."""
command = f"printf '{sequence}'\\n" command = f"printf '{sequence}'\\n"
if surface is None: if surface is None:

View file

@ -202,13 +202,27 @@ print("TIMEOUT", flush=True)
) )
try: try:
# Wait for process to start # Wait for process to start and emit the ready line
time.sleep(0.2) output = b""
start = time.time()
while time.time() - start < 2.0:
if select.select([proc.stdout], [], [], 0.1)[0]:
chunk = os.read(proc.stdout.fileno(), 1024)
if not chunk:
break
output += chunk
if b"WAITING" in output:
break
if b"WAITING" not in output:
print(f" ❌ FAILED: Process not ready. Output: {output}")
return False
# Send SIGINT directly # Send SIGINT directly
proc.send_signal(signal.SIGINT) proc.send_signal(signal.SIGINT)
stdout, stderr = proc.communicate(timeout=2) stdout, stderr = proc.communicate(timeout=2)
stdout = output + stdout
if b"SIGINT_RECEIVED" in stdout: if b"SIGINT_RECEIVED" in stdout:
print(" ✅ PASSED: Direct SIGINT works") print(" ✅ PASSED: Direct SIGINT works")