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
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:
```bash
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:
```bash
@ -28,12 +40,20 @@ xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -des
## 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
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
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 */; };
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
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 */; };
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.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 */; };
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
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 */; };
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
/* End PBXBuildFile section */
@ -52,6 +56,17 @@
name = "Embed Frameworks";
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 */
/* Begin PBXContainerItemProxy section */
@ -62,6 +77,13 @@
remoteGlobalIDString = A5001050 /* GhosttyTabs */;
remoteInfo = GhosttyTabs;
};
B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5001070 /* Project object */;
proxyType = 1;
remoteGlobalIDString = B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */;
remoteInfo = "cmuxterm-cli";
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
@ -76,6 +98,7 @@
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>"; };
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>"; };
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>"; };
@ -99,7 +122,10 @@
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>"; };
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 */
/* Begin PBXFrameworksBuildPhase section */
@ -119,6 +145,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B900000CA1B2C3D4E5F60719 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXResourcesBuildPhase section */
@ -140,11 +173,32 @@
};
/* 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 */
A5001040 = {
isa = PBXGroup;
children = (
A5001041 /* Sources */,
B9000003A1B2C3D4E5F60719 /* CLI */,
087C454FFF74443AB06942C3 /* Resources */,
A5001101 /* Assets.xcassets */,
A5001016 /* GhosttyKit.xcframework */,
@ -164,6 +218,7 @@
A5001014 /* GhosttyConfig.swift */,
A5001015 /* GhosttyTerminalView.swift */,
A5001019 /* TerminalController.swift */,
A5001225 /* SocketControlSettings.swift */,
A5001090 /* AppDelegate.swift */,
A5001091 /* NotificationsPage.swift */,
A5001092 /* TerminalNotificationStore.swift */,
@ -188,6 +243,14 @@
path = Sources;
sourceTree = "<group>";
};
B9000003A1B2C3D4E5F60719 /* CLI */ = {
isa = PBXGroup;
children = (
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */,
);
path = CLI;
sourceTree = "<group>";
};
087C454FFF74443AB06942C3 /* Resources */ = {
isa = PBXGroup;
children = (
@ -200,6 +263,7 @@
isa = PBXGroup;
children = (
A5001000 /* cmuxterm.app */,
B9000004A1B2C3D4E5F60719 /* cmuxterm */,
7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */,
);
name = Products;
@ -208,6 +272,7 @@
3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = {
isa = PBXGroup;
children = (
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */,
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
);
@ -223,12 +288,15 @@
buildPhases = (
A5001051 /* Sources */,
A5001030 /* Frameworks */,
A5001300A1B2C3D4E5F60719 /* Copy Ghostty Resources */,
A5001102 /* Resources */,
A5001020 /* Embed Frameworks */,
B900000AA1B2C3D4E5F60719 /* Copy CLI */,
);
buildRules = (
);
dependencies = (
B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */,
);
packageProductDependencies = (
A5001231 /* Sparkle */,
@ -238,6 +306,22 @@
productReference = A5001000 /* cmuxterm.app */;
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 */ = {
isa = PBXNativeTarget;
buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */;
@ -283,6 +367,7 @@
projectRoot = "";
targets = (
A5001050 /* GhosttyTabs */,
B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */,
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */,
);
};
@ -299,6 +384,7 @@
A5001004 /* GhosttyConfig.swift in Sources */,
A5001005 /* GhosttyTerminalView.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */,
A5001093 /* AppDelegate.swift in Sources */,
A5001094 /* NotificationsPage.swift in Sources */,
A5001095 /* TerminalNotificationStore.swift in Sources */,
@ -326,11 +412,20 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */,
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B9000006A1B2C3D4E5F60719 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B9000002A1B2C3D4E5F60719 /* cmuxterm.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -339,6 +434,11 @@
target = A5001050 /* GhosttyTabs */;
targetProxy = 738BF3D3196765B250928A93 /* PBXContainerItemProxy */;
};
B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */;
targetProxy = B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@ -494,6 +594,30 @@
};
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 */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -567,6 +691,15 @@
defaultConfigurationIsVisible = 0;
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" */ = {
isa = XCConfigurationList;
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
- **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
- **Auto-updates** — Stay current with Sparkle-powered updates
## Why cmuxterm?

View file

@ -43,10 +43,18 @@ struct GhosttyConfig {
static func load() -> GhosttyConfig {
var config = GhosttyConfig()
// Load user config
let configPath = NSString(string: "~/Library/Application Support/com.mitchellh.ghostty/config").expandingTildeInPath
if let contents = try? String(contentsOfFile: configPath, encoding: .utf8) {
config.parse(contents)
// Match Ghostty's default load order on macOS.
let configPaths = [
"~/.config/ghostty/config",
"~/.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
@ -137,11 +145,15 @@ struct GhosttyConfig {
}
mutating func loadTheme(_ name: String) {
// Try to load from Ghostty app resources
let bundleThemePath = Bundle.main.resourceURL?
.appendingPathComponent("ghostty/themes/\(name)")
.path
let themePaths = [
bundleThemePath,
"/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 {
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 {

View file

@ -3,6 +3,7 @@ import SwiftUI
import AppKit
import Metal
import QuartzCore
import Darwin
private enum GhosttyPasteboardHelper {
private static let selectionPasteboard = NSPasteboard(
@ -353,6 +354,7 @@ class GhosttyApp {
tabId: tabId,
surfaceId: surfaceId,
title: command,
subtitle: "",
body: body
)
}
@ -509,6 +511,7 @@ class GhosttyApp {
tabId: tabId,
surfaceId: surfaceId,
title: command,
subtitle: "",
body: body
)
}
@ -637,8 +640,63 @@ class TerminalSurface: Identifiable {
surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque()
surfaceConfig.scale_factor = scale
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 {
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 {
focusedSurface?.applyWindowBackgroundIfActive()
}
let isAppFocused = AppFocusState.isAppFocused()
guard isSelectedTab && isAppFocused else { return }
let isAppActive = AppFocusState.isAppActive()
guard isSelectedTab && isAppActive else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) {
triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false)
@ -465,7 +465,7 @@ class TabManager: ObservableObject {
let shouldSuppressFlash = suppressFocusFlash
suppressFocusFlash = false
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppFocused() else { return }
guard AppFocusState.isAppActive() else { return }
guard let surfaceId = focusedSurfaceId(for: tabId) else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }

View file

@ -6,16 +6,28 @@ import Foundation
class TerminalController {
static let shared = TerminalController()
private let socketPath = "/tmp/cmux.sock"
private var socketPath = "/tmp/cmuxterm.sock"
private var serverSocket: Int32 = -1
private var isRunning = false
private var clientHandlers: [Int32: Thread] = [:]
private weak var tabManager: TabManager?
private var accessMode: SocketControlMode = .full
private init() {}
func start(tabManager: TabManager) {
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
self.tabManager = tabManager
self.accessMode = accessMode
if isRunning {
if self.socketPath == socketPath {
self.accessMode = accessMode
return
}
stop()
}
self.socketPath = socketPath
// Remove existing socket file
unlink(socketPath)
@ -133,6 +145,9 @@ class TerminalController {
let cmd = parts[0].lowercased()
let args = parts.count > 1 ? parts[1] : ""
if !isCommandAllowed(cmd) {
return "ERROR: Command disabled by socket access mode"
}
switch cmd {
case "ping":
@ -180,6 +195,9 @@ class TerminalController {
case "notify_surface":
return notifySurface(args)
case "notify_target":
return notifyTarget(args)
case "list_notifications":
return listNotifications()
@ -227,8 +245,9 @@ class TerminalController {
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_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_surface <id|idx> <title>|<body> - Create a notification for a surface
notify <title>|<subtitle>|<body> - Create a notification for the focused 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
clear_notifications - Clear all notifications
set_app_focus <active|inactive|clear> - Override app focus state
@ -246,6 +265,26 @@ class TerminalController {
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 {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
@ -350,11 +389,12 @@ class TerminalController {
return
}
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
let (title, body) = parseNotificationPayload(args)
let (title, subtitle, body) = parseNotificationPayload(args)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
title: title,
subtitle: subtitle,
body: body
)
}
@ -381,11 +421,47 @@ class TerminalController {
result = "ERROR: Surface not found"
return
}
let (title, body) = parseNotificationPayload(payload)
let (title, subtitle, body) = parseNotificationPayload(payload)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
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
)
}
@ -398,7 +474,7 @@ class TerminalController {
let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in
let surfaceText = notification.surfaceId?.uuidString ?? "none"
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")
}
@ -559,13 +635,16 @@ class TerminalController {
return nil
}
private func parseNotificationPayload(_ args: String) -> (String, String) {
private func parseNotificationPayload(_ args: String) -> (String, String, String) {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return ("Notification", "") }
let parts = trimmed.split(separator: "|", maxSplits: 1).map(String.init)
guard !trimmed.isEmpty else { return ("Notification", "", "") }
let parts = trimmed.split(separator: "|", maxSplits: 2).map(String.init)
let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
let body = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
return (title.isEmpty ? "Notification" : title, body)
let subtitle = parts.count > 2 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
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 {

View file

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

View file

@ -8,17 +8,25 @@ struct WindowAccessor: NSViewRepresentable {
Coordinator()
}
func makeNSView(context: Context) -> NSView {
NSView()
}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async { [weak nsView] in
guard let window = nsView?.window else { return }
func makeNSView(context: Context) -> WindowObservingView {
let view = WindowObservingView()
view.onWindow = { window in
guard context.coordinator.lastWindow !== window else { return }
context.coordinator.lastWindow = 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?
}
}
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 SwiftUI
import Darwin
@main
struct cmuxApp: App {
@ -12,6 +13,7 @@ struct cmuxApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
configureGhosttyEnvironment()
// Start the terminal controller for programmatic control
// This runs after TabManager is created via @StateObject
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 {
WindowGroup {
ContentView(updateViewModel: appDelegate.updateViewModel)
@ -50,6 +103,7 @@ struct cmuxApp: App {
Settings {
SettingsRootView()
}
.defaultSize(width: 460, height: 280)
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .appInfo) {
@ -327,6 +381,7 @@ struct SettingsView: View {
.foregroundColor(.secondary)
}
.padding(20)
.padding(.top, 4)
.frame(minWidth: 360, minHeight: 280)
}
}
@ -341,12 +396,21 @@ private struct SettingsRootView: View {
private func configureSettingsWindow(_ window: NSWindow) {
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
window.title = ""
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.titlebarAppearsTransparent = false
window.styleMask.remove(.fullSizeContentView)
window.styleMask.insert(.resizable)
window.contentMinSize = NSSize(width: 360, height: 280)
if window.frame.width > 520 {
window.setContentSize(NSSize(width: 460, height: max(280, window.contentView?.frame.height ?? 280)))
if window.toolbar == nil {
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

@ -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 select
import os
from typing import Optional, List, Tuple
import time
import errno
from typing import Optional, List, Tuple, Union
class cmuxError(Exception):
@ -39,10 +41,21 @@ class cmuxError(Exception):
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:
"""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):
self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH
@ -54,19 +67,30 @@ class cmux:
if self._socket is not None:
return
if not os.path.exists(self.socket_path):
raise cmuxError(
f"Socket not found at {self.socket_path}. "
"Is cmux running?"
)
start = time.time()
while not os.path.exists(self.socket_path):
if time.time() - start >= 2.0:
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)
try:
self._socket.connect(self.socket_path)
self._socket.settimeout(5.0)
except socket.error as e:
self._socket = None
raise cmuxError(f"Failed to connect: {e}")
last_error: Optional[socket.error] = None
while True:
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
self._socket.connect(self.socket_path)
self._socket.settimeout(5.0)
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:
"""Close the connection"""
@ -91,20 +115,26 @@ class cmux:
self._socket.sendall((command + "\n").encode())
data = self._recv_buffer
self._recv_buffer = ""
saw_newline = "\n" in data
start = time.time()
while True:
if "\n" not in data:
chunk = self._socket.recv(8192)
if not chunk:
if saw_newline:
ready, _, _ = select.select([self._socket], [], [], 0.1)
if not ready:
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
ready, _, _ = select.select([self._socket], [], [], 0.01)
if not ready:
break
chunk = self._socket.recv(8192)
if not chunk:
break
data += chunk.decode()
if "\n" in data:
saw_newline = True
if data.endswith("\n"):
data = data[:-1]
return data
@ -159,13 +189,13 @@ class cmux:
if not response.startswith("OK"):
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"""
response = self._send_command(f"select_tab {tab}")
if not response.startswith("OK"):
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.
If tab is None, uses the current tab.
@ -187,7 +217,7 @@ class cmux:
surfaces.append((index, surface_id, selected))
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."""
response = self._send_command(f"focus_surface {surface}")
if not response.startswith("OK"):
@ -216,7 +246,7 @@ class cmux:
if not response.startswith("OK"):
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."""
escaped = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
response = self._send_command(f"send_surface {surface} {escaped}")
@ -236,7 +266,7 @@ class cmux:
if not response.startswith("OK"):
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."""
response = self._send_command(f"send_key_surface {surface} {key}")
if not response.startswith("OK"):
@ -258,16 +288,22 @@ class cmux:
"""Get help text from server"""
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."""
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}")
if not response.startswith("OK"):
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."""
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}")
if not response.startswith("OK"):
raise cmuxError(response)
@ -275,7 +311,7 @@ class cmux:
def list_notifications(self) -> list[dict]:
"""
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")
if response == "No notifications":
@ -286,16 +322,17 @@ class cmux:
if not line.strip():
continue
_, payload = line.split(":", 1)
parts = payload.split("|", 5)
if len(parts) < 6:
parts = payload.split("|", 6)
if len(parts) < 7:
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({
"id": notif_id,
"tab_id": tab_id,
"surface_id": None if surface_id == "none" else surface_id,
"is_read": read_text == "read",
"title": title,
"subtitle": subtitle,
"body": body,
})
return items
@ -306,7 +343,7 @@ class cmux:
if not response.startswith("OK"):
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."""
if active is None:
value = "clear"
@ -322,7 +359,7 @@ class cmux:
if not response.startswith("OK"):
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."""
if surface is None:
command = f"focus_notification {tab}"
@ -332,7 +369,7 @@ class cmux:
if not response.startswith("OK"):
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."""
response = self._send_command(f"flash_count {surface}")
if response.startswith("OK "):

View file

@ -13,6 +13,7 @@ import sys
import time
import subprocess
from pathlib import Path
from typing import Optional
# Add the directory containing cmux.py to the path
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
def find_config_with_keybind() -> Path | None:
def find_config_with_keybind() -> Optional[Path]:
home = Path.home()
candidates = [
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
client.send("sleep 30\n")
time.sleep(0.3)
time.sleep(0.8)
# Send Ctrl+C to interrupt
client.send_ctrl_c()
time.sleep(0.3)
time.sleep(0.8)
# If Ctrl+C worked, shell should accept new command
client.send(f"touch {marker}\n")
time.sleep(0.5)
for attempt in range(3):
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():
result.success("Ctrl+C interrupted sleep, shell responsive")
@ -104,15 +113,18 @@ def test_ctrl_d(client: cmux) -> TestResult:
# Run cat (waits for input)
client.send("cat\n")
time.sleep(0.3)
time.sleep(0.6)
# Send Ctrl+D (EOF)
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
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():
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
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
client.send_ctrl_c()
time.sleep(0.5)
time.sleep(0.6)
# If Ctrl+C worked, shell should accept new command
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():
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 sys
import time
from typing import Optional
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]
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."""
command = f"printf '{sequence}'\\n"
if surface is None:

View file

@ -202,13 +202,27 @@ print("TIMEOUT", flush=True)
)
try:
# Wait for process to start
time.sleep(0.2)
# Wait for process to start and emit the ready line
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
proc.send_signal(signal.SIGINT)
stdout, stderr = proc.communicate(timeout=2)
stdout = output + stdout
if b"SIGINT_RECEIVED" in stdout:
print(" ✅ PASSED: Direct SIGINT works")