Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f34f8c1
Sync ghostty fork with upstream main
lawrencecchen Mar 16, 2026
d768fe6
Keep main titlebar opaque on macOS 26
lawrencecchen Mar 17, 2026
2667d22
Fix Ghostty search and chrome integration after upstream sync
lawrencecchen Mar 18, 2026
fcdeac5
Fix Ghostty search fallback after upstream sync
lawrencecchen Mar 18, 2026
0609f57
Add automation socket send-key UI regression test
lawrencecchen Mar 20, 2026
6105c98
Fix empty display env handling in E2E workflow
lawrencecchen Mar 20, 2026
d937c39
Bootstrap terminal surface in automation socket UI test
lawrencecchen Mar 20, 2026
605fe5c
Fix automation socket UI test path resolution
lawrencecchen Mar 20, 2026
306b161
Route automation socket UI test to explicit window
lawrencecchen Mar 20, 2026
40132e5
Log raw automation socket responses in UI test
lawrencecchen Mar 20, 2026
d33e98e
Fix automation socket UI test CLI harness
lawrencecchen Mar 20, 2026
bfeb362
Prefer newest Xcode in E2E workflow
lawrencecchen Mar 20, 2026
8ecb149
Fallback to env cmux in automation socket UI test
lawrencecchen Mar 20, 2026
f363936
Try standalone cmux CLI in automation socket UI test
lawrencecchen Mar 20, 2026
09ad5a2
Use direct socket client in automation socket UI test
lawrencecchen Mar 20, 2026
b2f99cd
Merge remote-tracking branch 'origin/main' into task-sync-ghostty-ups…
lawrencecchen Mar 20, 2026
1b49b41
Fix focused E2E workflow shell script
lawrencecchen Mar 21, 2026
835ed70
Stabilize automation socket UI test launch
lawrencecchen Mar 21, 2026
f9fceed
Use netcat for automation socket UI tests
lawrencecchen Mar 21, 2026
c3df2e2
Force socket mode via environment in UI tests
lawrencecchen Mar 21, 2026
922b961
Log automation socket UI test diagnostics
lawrencecchen Mar 21, 2026
ede6ea4
Lowercase automation socket UI test tag
lawrencecchen Mar 21, 2026
bfd9294
Stabilize automation socket UI test foreground launch
lawrencecchen Mar 21, 2026
12a8769
Use clean UI test launch contract for automation socket tests
lawrencecchen Mar 21, 2026
8752400
Use direct socket client in automation UI test
lawrencecchen Mar 21, 2026
06da3d9
Harden automation socket UI test startup
lawrencecchen Mar 21, 2026
d14c7de
Use allowAll for repeated socket UI test
lawrencecchen Mar 21, 2026
2bb008d
Stabilize automation socket UI test harness
lawrencecchen Mar 21, 2026
85e0303
Run automation socket stress off main thread
lawrencecchen Mar 21, 2026
ed9da3e
Resolve automation socket test target via socket
lawrencecchen Mar 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions .github/workflows/test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,14 @@ jobs:
- name: Select Xcode
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
if compgen -G "/Applications/Xcode_*.app" >/dev/null 2>&1; then
XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -n 1)"
XCODE_DIR="$XCODE_APP/Contents/Developer"
elif [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
if [ -n "$XCODE_APP" ]; then
XCODE_DIR="$XCODE_APP/Contents/Developer"
else
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
Expand Down Expand Up @@ -217,9 +215,7 @@ jobs:
clang -framework Foundation -framework CoreGraphics \
-o "$HELPER_PATH" scripts/create-virtual-display.m

cat >"$MANIFEST_PATH" <<EOF
{"helperBinaryPath":"$HELPER_PATH"}
EOF
printf '{"helperBinaryPath":"%s"}\n' "$HELPER_PATH" >"$MANIFEST_PATH"
fi

# Start recording right before the test (after build/resolve).
Expand Down
209 changes: 205 additions & 4 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1769,15 +1769,18 @@ func startOrFocusTerminalSearch(
return true
}

terminalSurface.requestGhosttySearchActivation(.startSearch)
if terminalSurface.performBindingAction("start_search") {
DispatchQueue.main.async { [weak terminalSurface] in
guard let terminalSurface, terminalSurface.searchState == nil else { return }
terminalSurface.searchState = TerminalSurface.SearchState()
searchFocusNotifier(terminalSurface)
DispatchQueue.main.async {
cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(
terminalSurface,
searchFocusNotifier: searchFocusNotifier
)
}
Comment on lines +1774 to 1779

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid run-loop deferral for search fallback state initialization.

At Line 1743, forcing cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(...) onto DispatchQueue.main.async creates a timing gap where this function returns true while searchState can still be nil. That can desync immediate search-active checks and overlay activation paths.

Suggested fix
-    if terminalSurface.performBindingAction("start_search") {
-        DispatchQueue.main.async {
-            cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(
-                terminalSurface,
-                searchFocusNotifier: searchFocusNotifier
-            )
-        }
+    if terminalSurface.performBindingAction("start_search") {
+        if Thread.isMainThread {
+            cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(
+                terminalSurface,
+                searchFocusNotifier: searchFocusNotifier
+            )
+        } else {
+            DispatchQueue.main.async {
+                cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(
+                    terminalSurface,
+                    searchFocusNotifier: searchFocusNotifier
+                )
+            }
+        }
         return true
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 1743 - 1748, The call to
cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded is being deferred with
DispatchQueue.main.async which creates a timing gap where the function can
return true while searchState is still nil; remove the async deferral and invoke
cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(terminalSurface,
searchFocusNotifier: searchFocusNotifier) synchronously on the current execution
context (or, if it must run on the main thread, perform a main-thread safe
synchronous invocation—i.e. call directly when already on main, otherwise
dispatch synchronously to DispatchQueue.main) so that searchState is initialized
deterministically before any immediate search-active checks or overlay
activation logic runs.

return true
}

terminalSurface.clearGhosttySearchActivationRequest()
terminalSurface.searchState = TerminalSurface.SearchState()
searchFocusNotifier(terminalSurface)
return true
Expand Down Expand Up @@ -2076,6 +2079,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var bonsplitTabDragUITestRecorder: DispatchSourceTimer?
private var gotoSplitUITestObservers: [NSObjectProtocol] = []
private var didSetupMultiWindowNotificationsUITest = false
private var didSetupAutomationSocketStressUITest = false
private var didSetupDisplayResolutionUITestDiagnostics = false
private var displayResolutionUITestObservers: [NSObjectProtocol] = []
private struct UITestRenderDiagnosticsSnapshot {
Expand Down Expand Up @@ -2459,6 +2463,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return object
}

private func updateUITestDiagnosticsIfNeeded(_ updates: [String: String]) {
let env = ProcessInfo.processInfo.environment
guard let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return }

var payload = loadUITestDiagnostics(at: path)
for (key, value) in updates {
payload[key] = value
}
Comment on lines +2470 to +2473

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize diagnostics file updates across threads

updateUITestDiagnosticsIfNeeded performs a read-modify-write on the diagnostics JSON without synchronization, and this helper is invoked from the background stress loop while other diagnostics writers on main also rewrite the same file. These concurrent writes can overwrite each other and drop fields like automationSocketStressDone or trace data, making the UI test completion wait flaky. Use a single serial queue/lock for all diagnostics file writes.

Useful? React with 👍 / 👎.


guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
}

private func appendUITestSocketDiagnosticsIfNeeded(
_ payload: inout [String: String],
environment env: [String: String]
Expand Down Expand Up @@ -2679,6 +2696,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
setupGotoSplitUITestIfNeeded()
setupBonsplitTabDragUITestIfNeeded()
setupMultiWindowNotificationsUITestIfNeeded()
setupAutomationSocketStressUITestIfNeeded(tabManager: tabManager)
setupDisplayResolutionUITestDiagnosticsIfNeeded()

// UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM.
Expand Down Expand Up @@ -2733,6 +2751,189 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}

private func setupAutomationSocketStressUITestIfNeeded(tabManager: TabManager) {
guard !didSetupAutomationSocketStressUITest else { return }
didSetupAutomationSocketStressUITest = true

let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] == "1" else { return }

updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": "waiting",
"automationSocketStressDone": "0",
"automationSocketStressIterationsCompleted": "0",
"automationSocketStressTrace": "scheduled",
])

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.runAutomationSocketStressUITestAttempt(tabManager: tabManager, remainingAttempts: 40)
}
}

private func runAutomationSocketStressUITestAttempt(tabManager: TabManager, remainingAttempts: Int) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] == "1" else { return }

guard let config = socketListenerConfigurationIfEnabled() else {
finishAutomationSocketStressAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts,
status: "waiting",
trace: ["socket_disabled"]
)
return
}

let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path)
let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath)
let pingResponse = health.isHealthy
? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0)
: nil

guard health.isHealthy, pingResponse == "PONG" else {
finishAutomationSocketStressAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts,
status: "waiting",
trace: [
"socket=\(socketPath)",
"isHealthy=\(health.isHealthy ? "1" : "0")",
"ping=\(pingResponse ?? "<nil>")",
]
)
return
}

guard let target = resolvedAutomationSocketStressTarget(tabManager: tabManager) else {
finishAutomationSocketStressAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts,
status: "waiting",
trace: [
"socket=\(socketPath)",
"ping=PONG",
"target=<nil>",
]
)
return
}

let workspaceId = target.workspaceId.uuidString
let surfaceId = target.surfaceId.uuidString
var trace: [String] = [
"socket=\(socketPath)",
"workspace=\(workspaceId)",
"surface=\(surfaceId)",
"baseline.list=\(TerminalController.probeSocketCommand("list_surfaces \(workspaceId)", at: socketPath, timeout: 1.0) ?? "<nil>")",
]

for iteration in 1...8 {
let pingBefore = TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0)
let sendResponse = TerminalController.probeSocketCommand(
"send_key_surface \(surfaceId) enter",
at: socketPath,
timeout: 2.0
)
let pingAfter = TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0)
let listResponse = TerminalController.probeSocketCommand(
"list_surfaces \(workspaceId)",
at: socketPath,
timeout: 2.0
)

trace.append(
"iteration\(iteration)=pingBefore:\(pingBefore ?? "<nil>"),send:\(sendResponse ?? "<nil>"),pingAfter:\(pingAfter ?? "<nil>"),list:\(listResponse ?? "<nil>")"
)

guard pingBefore == "PONG",
sendResponse == "OK",
pingAfter == "PONG",
automationSocketStressListResponse(listResponse, containsSurface: surfaceId) else {
updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": "failed",
"automationSocketStressDone": "1",
"automationSocketStressIterationsCompleted": String(iteration - 1),
"automationSocketStressTrace": trace.joined(separator: " | "),
])
return
}
}

updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": "passed",
"automationSocketStressDone": "1",
"automationSocketStressIterationsCompleted": "8",
"automationSocketStressTrace": trace.joined(separator: " | "),
])
}

private func finishAutomationSocketStressAttempt(
tabManager: TabManager,
remainingAttempts: Int,
status: String,
trace: [String]
) {
updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": status,
"automationSocketStressDone": "0",
"automationSocketStressIterationsCompleted": "0",
"automationSocketStressTrace": trace.joined(separator: " | "),
])

guard remainingAttempts > 1 else {
updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": "failed",
"automationSocketStressDone": "1",
"automationSocketStressIterationsCompleted": "0",
"automationSocketStressTrace": trace.joined(separator: " | "),
])
return
}

DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.runAutomationSocketStressUITestAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts - 1
)
}
}

private func resolvedAutomationSocketStressTarget(tabManager: TabManager) -> (workspaceId: UUID, surfaceId: UUID)? {
guard let workspaceId = tabManager.selectedTabId ?? tabManager.tabs.first?.id else {
return nil
}

if let surfaceId = tabManager.focusedPanelId(for: workspaceId) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return (workspaceId: workspaceId, surfaceId: surfaceId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pick terminal panel before running socket send-key stress

resolvedAutomationSocketStressTarget returns tabManager.focusedPanelId immediately, but that ID can belong to a browser panel. The stress loop then calls send_key_surface <id> enter and expects OK, so any workspace focused on a non-terminal panel will deterministically fail this new UI-test harness even though terminal surfaces are available (the terminal-only fallbacks below are never reached once this early return runs). Restrict this first branch to focused terminal panels (or validate the ID is terminal) before using it as the send-key target.

Useful? React with 👍 / 👎.


guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
return nil
}

if let focusedTerminalPanel = workspace.focusedTerminalPanel {
return (workspaceId: workspaceId, surfaceId: focusedTerminalPanel.id)
}

if let inheritedTerminalPanel = workspace.terminalPanelForConfigInheritance() {
return (workspaceId: workspaceId, surfaceId: inheritedTerminalPanel.id)
}

guard let firstTerminalSurfaceId = workspace.panels.values
.compactMap({ ($0 as? TerminalPanel)?.id })
.sorted(by: { $0.uuidString < $1.uuidString })
.first else {
return nil
}

return (workspaceId: workspaceId, surfaceId: firstTerminalSurfaceId)
}

private func automationSocketStressListResponse(_ response: String?, containsSurface surfaceId: String) -> Bool {
guard let response, !response.isEmpty, response != "No surfaces" else { return false }
return response.contains(surfaceId)
}

private func setupDisplayResolutionUITestDiagnosticsIfNeeded() {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return }
Expand Down
57 changes: 47 additions & 10 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,44 @@ struct TitlebarLayerBackground: NSViewRepresentable {
}
}

func cmuxMainWindowTitlebarOpacity(
backgroundOpacity: Double
) -> CGFloat {
GhosttyBackgroundTheme.compositedChromeOpacity(backgroundOpacity)
}

func cmuxResolveGhosttyChromeConfig(
loadedConfig: GhosttyConfig,
runtimeBackgroundColor: NSColor,
runtimeBackgroundOpacity: Double
) -> GhosttyConfig {
let runtimeOpacity = GhosttyBackgroundTheme.clampedOpacity(runtimeBackgroundOpacity)
let loadedOpacity = GhosttyBackgroundTheme.clampedOpacity(loadedConfig.backgroundOpacity)

// Newer Ghostty builds can drive the window background fully clear at runtime for
// translucent terminals. That clear backing color should not replace cmux chrome.
guard runtimeOpacity > 0.001 || loadedOpacity <= 0.001 else {
return loadedConfig
}

var resolved = loadedConfig
resolved.backgroundColor = runtimeBackgroundColor
resolved.backgroundOpacity = runtimeBackgroundOpacity
return resolved
}

func cmuxResolveGhosttyChromeConfig(
loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() },
runtimeBackgroundColor: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor },
runtimeBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity }
) -> GhosttyConfig {
cmuxResolveGhosttyChromeConfig(
loadedConfig: loadConfig(),
runtimeBackgroundColor: runtimeBackgroundColor(),
runtimeBackgroundOpacity: runtimeBackgroundOpacity()
)
}

final class SidebarState: ObservableObject {
@Published var isVisible: Bool
@Published var persistedWidth: CGFloat
Expand Down Expand Up @@ -1457,6 +1495,7 @@ struct ContentView: View {
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
@State private var didApplyUITestSidebarSelection = false
@State private var titlebarThemeGeneration: UInt64 = 0
@State private var titlebarThemeConfig = cmuxResolveGhosttyChromeConfig()
@State private var sidebarDraggedTabId: UUID?
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
@State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem?
Expand Down Expand Up @@ -2183,8 +2222,7 @@ struct ContentView: View {
@State private var titlebarLeadingInset: CGFloat = 12
private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" }
private var fakeTitlebarTextColor: Color {
_ = titlebarThemeGeneration
let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor
let ghosttyBackground = titlebarThemeConfig.backgroundColor
return ghosttyBackground.isLightColor
? Color.black.opacity(0.78)
: Color.white.opacity(0.82)
Expand Down Expand Up @@ -2242,14 +2280,11 @@ struct ContentView: View {
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.background({
// The terminal area has two stacked semi-transparent layers: the Bonsplit
// container chrome background plus Ghostty's own Metal-rendered background.
// Compute the effective composited opacity so the titlebar matches visually.
let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity)
let effective = alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2)
return TitlebarLayerBackground(
backgroundColor: GhosttyApp.shared.defaultBackgroundColor,
opacity: effective
backgroundColor: titlebarThemeConfig.backgroundColor,
opacity: cmuxMainWindowTitlebarOpacity(
backgroundOpacity: titlebarThemeConfig.backgroundOpacity
)
)
}())
.overlay(alignment: .bottom) {
Expand Down Expand Up @@ -2408,6 +2443,7 @@ struct ContentView: View {
syncSidebarSelectedWorkspaceIds()
applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs)
updateTitlebarText()
titlebarThemeConfig = cmuxResolveGhosttyChromeConfig()

// Startup recovery (#399): if session restore or a race condition leaves the
// view in a broken state (empty tabs, no selection, unmounted workspaces),
Expand Down Expand Up @@ -2529,9 +2565,10 @@ struct ContentView: View {
})

view = AnyView(view.onChange(of: titlebarThemeGeneration) { oldValue, newValue in
titlebarThemeConfig = cmuxResolveGhosttyChromeConfig()
Comment on lines 2567 to +2568

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute titlebar theme when workspace selection changes

Updating titlebarThemeConfig only in the titlebarThemeGeneration observer makes the titlebar color/opacity depend on a later theme-refresh event, but switching to an already-mounted workspace with a different background can happen without bumping that generation. In that case the titlebar keeps the previous workspace's theme until another background notification arrives, so the chrome becomes visibly stale right after tab/workspace switches.

Useful? React with 👍 / 👎.

guard GhosttyApp.shared.backgroundLogEnabled else { return }
GhosttyApp.shared.logBackground(
"titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
"titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) titlebarBg=\(titlebarThemeConfig.backgroundColor.hexString()) titlebarOpacity=\(String(format: "%.3f", titlebarThemeConfig.backgroundOpacity)) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
)
})

Expand Down
Loading
Loading