diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b831e0e..1f99c9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to cmux are documented here. +## [1.25.0] - 2026-02-11 + +### Fixed +- Blank terminal on macOS 26 (Tahoe) — two additional code paths were still clearing the window background, bypassing the initial fix +- Blank terminal on macOS 15 caused by background blur view covering terminal content + ## [1.24.0] - 2026-02-09 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 709a0cdf..f9c86b7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,12 @@ cd cmuxd && zig build -Doptimize=ReleaseFast ./scripts/reloadp.sh ``` +`reloads` = kill and launch the Release app as "cmux STAGING" (isolated from production cmux): + +```bash +./scripts/reloads.sh +``` + `reload2` = reload both Debug and Release: ```bash diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index dea9b11b..0fc6733e 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -538,7 +538,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -554,7 +554,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.24.0; + MARKETING_VERSION = 1.25.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -583,7 +583,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -599,7 +599,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.24.0; + MARKETING_VERSION = 1.25.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -652,10 +652,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.24.0; + MARKETING_VERSION = 1.25.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -669,10 +669,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.24.0; + MARKETING_VERSION = 1.25.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2710515c..ed2e98f6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -44,9 +44,9 @@ enum WindowGlassEffect { return } - // Older macOS: insert blur as a background subview instead of replacing contentView. - // Replacing contentView on macOS 13-15 breaks traffic light rendering when the - // window uses fullSizeContentView + titlebarAppearsTransparent. + // Older macOS: insert blur below the contentView in the window's internal view + // hierarchy. Adding to contentView directly gets reordered by SwiftUI, causing + // the blur view to cover the terminal content. let blurView = NSVisualEffectView(frame: bounds) blurView.blendingMode = .behindWindow blurView.material = .hudWindow @@ -54,7 +54,11 @@ enum WindowGlassEffect { blurView.wantsLayer = true blurView.autoresizingMask = [.width, .height] - contentView.addSubview(blurView, positioned: .below, relativeTo: contentView.subviews.first) + if let themeFrame = contentView.superview { + themeFrame.addSubview(blurView, positioned: .below, relativeTo: contentView) + } else { + contentView.addSubview(blurView, positioned: .below, relativeTo: contentView.subviews.first) + } // Tint overlay on top of blur, still behind content if let color = tintColor { @@ -62,7 +66,11 @@ enum WindowGlassEffect { tintOverlay.autoresizingMask = [.width, .height] tintOverlay.wantsLayer = true tintOverlay.layer?.backgroundColor = color.cgColor - contentView.addSubview(tintOverlay, positioned: .above, relativeTo: blurView) + if let themeFrame = contentView.superview { + themeFrame.addSubview(tintOverlay, positioned: .above, relativeTo: blurView) + } else { + contentView.addSubview(tintOverlay, positioned: .above, relativeTo: blurView) + } objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8fc6fc14..7c2c2dcf 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -696,8 +696,8 @@ class GhosttyApp { private func applyBackgroundToKeyWindow() { guard let window = activeMainWindow() else { return } // Check if sidebar uses behindWindow blur - if so, keep window non-opaque - let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "withinWindow" - if sidebarBlendMode == "behindWindow" { + let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "behindWindow" + if sidebarBlendMode == "behindWindow" && !WindowGlassEffect.isAvailable { window.backgroundColor = .clear window.isOpaque = false if backgroundLogEnabled { @@ -1141,8 +1141,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { applySurfaceBackground() let color = effectiveBackgroundColor() // Check if sidebar uses behindWindow blur - if so, keep window non-opaque - let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "withinWindow" - if sidebarBlendMode == "behindWindow" { + let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "behindWindow" + if sidebarBlendMode == "behindWindow" && !WindowGlassEffect.isAvailable { window.backgroundColor = .clear window.isOpaque = false } else { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2f46af08..b4dcb2e2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -267,6 +267,12 @@ class TerminalController { case "reset_sidebar": return resetSidebar(args) + case "read_screen": + return readScreen() + + case "window_debug": + return windowDebug() + case "help": return helpText() @@ -312,6 +318,7 @@ class TerminalController { clear_ports [--tab=X] [--panel=Y] - Clear listening ports sidebar_state [--tab=X] - Dump all sidebar metadata reset_sidebar [--tab=X] - Clear all sidebar metadata + read_screen - Read visible terminal text from focused surface help - Show this help """ #if DEBUG @@ -976,6 +983,116 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } + private func readScreen() -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + + var result = "ERROR: No focused surface" + DispatchQueue.main.sync { + guard let selectedId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { + result = "ERROR: No selected tab" + return + } + // Try focused surface first, fall back to first leaf in split tree + let termSurface = tab.focusedSurface ?? tab.splitTree.root?.leaves().first + guard let termSurface else { + result = "ERROR: No terminal surface (focusedSurfaceId=\(tab.focusedSurfaceId?.uuidString ?? "nil"), root=\(tab.splitTree.root == nil ? "nil" : "exists"), leaves=\(tab.splitTree.root?.leaves().count ?? 0))" + return + } + guard let surface = termSurface.surface else { + result = "ERROR: ghostty_surface is nil (surface id=\(termSurface.id), app=\(GhosttyApp.shared.app == nil ? "nil" : "exists"))" + return + } + + var text = ghostty_text_s() + let sel = ghostty_selection_s( + top_left: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0), + rectangle: false) + guard ghostty_surface_read_text(surface, sel, &text) else { + result = "" + return + } + defer { ghostty_surface_free_text(surface, &text) } + result = String(cString: text.text) + } + return result + } + + private func windowDebug() -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + + var lines: [String] = [] + DispatchQueue.main.sync { + guard let selectedId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { + lines.append("ERROR: No selected tab") + return + } + + // Window properties + if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "cmux.main" }) { + lines.append("window.windowNumber=\(window.windowNumber)") + lines.append("window.isOpaque=\(window.isOpaque)") + lines.append("window.backgroundColor=\(window.backgroundColor)") + lines.append("window.alphaValue=\(window.alphaValue)") + lines.append("window.isVisible=\(window.isVisible)") + lines.append("window.frame=\(window.frame)") + if let cv = window.contentView { + lines.append("contentView.frame=\(cv.frame)") + lines.append("contentView.isHidden=\(cv.isHidden)") + lines.append("contentView.alphaValue=\(cv.alphaValue)") + lines.append("contentView.subviews.count=\(cv.subviews.count)") + if let layer = cv.layer { + lines.append("contentView.layer.isOpaque=\(layer.isOpaque)") + lines.append("contentView.layer.opacity=\(layer.opacity)") + lines.append("contentView.layer.backgroundColor=\(String(describing: layer.backgroundColor))") + } + for (i, sub) in cv.subviews.enumerated() { + lines.append(" subview[\(i)]=\(type(of: sub)) frame=\(sub.frame) hidden=\(sub.isHidden) alpha=\(sub.alphaValue)") + if let layer = sub.layer { + lines.append(" layer.isOpaque=\(layer.isOpaque) opacity=\(layer.opacity) bg=\(String(describing: layer.backgroundColor))") + } + } + } + } else { + lines.append("ERROR: No main window found") + } + + // Surface properties + if let surface = tab.focusedSurface { + let hosted = surface.hostedView + lines.append("hostedView.frame=\(hosted.frame)") + lines.append("hostedView.isHidden=\(hosted.isHidden)") + lines.append("hostedView.alphaValue=\(hosted.alphaValue)") + lines.append("hostedView.superview=\(String(describing: type(of: hosted.superview as Any)))") + lines.append("hostedView.window=\(hosted.window != nil ? "yes" : "nil")") + if let layer = hosted.layer { + lines.append("hostedView.layer.isOpaque=\(layer.isOpaque)") + lines.append("hostedView.layer.opacity=\(layer.opacity)") + } + } else { + lines.append("surface=nil") + } + + // App-level + lines.append("defaultBackgroundColor=\(GhosttyApp.shared.defaultBackgroundColor)") + lines.append("defaultBackgroundOpacity=\(GhosttyApp.shared.defaultBackgroundOpacity)") + let blendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "behindWindow" + lines.append("sidebarBlendMode=\(blendMode)") + lines.append("WindowGlassEffect.isAvailable=\(WindowGlassEffect.isAvailable)") + } + return lines.joined(separator: "\n") + } + private func sendInputToSurface(_ args: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) diff --git a/docs-site/content/docs/changelog.mdx b/docs-site/content/docs/changelog.mdx index 75812b4d..31fa33b3 100644 --- a/docs-site/content/docs/changelog.mdx +++ b/docs-site/content/docs/changelog.mdx @@ -5,6 +5,12 @@ description: Release notes and version history for cmux All notable changes to cmux are documented here. +## [1.25.0] - 2026-02-11 + +### Fixed +- Blank terminal on macOS 26 (Tahoe) — two additional code paths were still clearing the window background, bypassing the initial fix +- Blank terminal on macOS 15 caused by background blur view covering terminal content + ## [1.24.0] - 2026-02-09 ### Changed diff --git a/scripts/reloads.sh b/scripts/reloads.sh new file mode 100755 index 00000000..7c6d332b --- /dev/null +++ b/scripts/reloads.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="cmux STAGING" +BUNDLE_ID="com.cmux.app.staging" +BASE_APP_NAME="cmux" +DERIVED_DATA="" +NAME_SET=0 +BUNDLE_SET=0 +DERIVED_SET=0 +TAG="" + +usage() { + cat <<'EOF' +Usage: ./scripts/reloads.sh [options] + +Release build with isolated "cmux STAGING" identity. Runs side-by-side with +the production cmux app. + +Options: + --tag Short tag for parallel builds (e.g., feature-xyz-lol). + Sets app name, bundle id, and derived data path unless overridden. + --name Override app display/bundle name. + --bundle-id Override bundle identifier. + --derived-data Override derived data path. + -h, --help Show this help. +EOF +} + +sanitize_bundle() { + local raw="$1" + local cleaned + cleaned="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/./g; s/^\\.+//; s/\\.+$//; s/\\.+/./g')" + if [[ -z "$cleaned" ]]; then + cleaned="agent" + fi + echo "$cleaned" +} + +sanitize_path() { + local raw="$1" + local cleaned + cleaned="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')" + if [[ -z "$cleaned" ]]; then + cleaned="agent" + fi + echo "$cleaned" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) + TAG="${2:-}" + if [[ -z "$TAG" ]]; then + echo "error: --tag requires a value" >&2 + exit 1 + fi + shift 2 + ;; + --name) + APP_NAME="${2:-}" + if [[ -z "$APP_NAME" ]]; then + echo "error: --name requires a value" >&2 + exit 1 + fi + NAME_SET=1 + shift 2 + ;; + --bundle-id) + BUNDLE_ID="${2:-}" + if [[ -z "$BUNDLE_ID" ]]; then + echo "error: --bundle-id requires a value" >&2 + exit 1 + fi + BUNDLE_SET=1 + shift 2 + ;; + --derived-data) + DERIVED_DATA="${2:-}" + if [[ -z "$DERIVED_DATA" ]]; then + echo "error: --derived-data requires a value" >&2 + exit 1 + fi + DERIVED_SET=1 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown option $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -n "$TAG" ]]; then + TAG_ID="$(sanitize_bundle "$TAG")" + TAG_SLUG="$(sanitize_path "$TAG")" + if [[ "$NAME_SET" -eq 0 ]]; then + APP_NAME="cmux STAGING ${TAG}" + fi + if [[ "$BUNDLE_SET" -eq 0 ]]; then + BUNDLE_ID="com.cmux.app.staging.${TAG_ID}" + fi + if [[ "$DERIVED_SET" -eq 0 ]]; then + DERIVED_DATA="/tmp/cmux-staging-${TAG_SLUG}" + fi +fi + +XCODEBUILD_ARGS=( + -project GhosttyTabs.xcodeproj + -scheme cmux + -configuration Release + -destination 'platform=macOS' +) +if [[ -n "$DERIVED_DATA" ]]; then + XCODEBUILD_ARGS+=(-derivedDataPath "$DERIVED_DATA") +fi +if [[ -z "$TAG" ]]; then + XCODEBUILD_ARGS+=( + INFOPLIST_KEY_CFBundleName="$APP_NAME" + INFOPLIST_KEY_CFBundleDisplayName="$APP_NAME" + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" + ) +fi +XCODEBUILD_ARGS+=(build) + +xcodebuild "${XCODEBUILD_ARGS[@]}" +sleep 0.2 + +FALLBACK_APP_NAME="$BASE_APP_NAME" +SEARCH_APP_NAME="$APP_NAME" +if [[ -n "$TAG" ]]; then + SEARCH_APP_NAME="$BASE_APP_NAME" +fi +if [[ -n "$DERIVED_DATA" ]]; then + APP_PATH="${DERIVED_DATA}/Build/Products/Release/${SEARCH_APP_NAME}.app" + if [[ ! -d "${APP_PATH}" && "$SEARCH_APP_NAME" != "$FALLBACK_APP_NAME" ]]; then + APP_PATH="${DERIVED_DATA}/Build/Products/Release/${FALLBACK_APP_NAME}.app" + fi +else + APP_BINARY="$( + find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/${SEARCH_APP_NAME}.app/Contents/MacOS/${SEARCH_APP_NAME}" -print0 \ + | xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \ + | sort -nr \ + | head -n 1 \ + | cut -d' ' -f2- + )" + if [[ -n "${APP_BINARY}" ]]; then + APP_PATH="$(dirname "$(dirname "$(dirname "$APP_BINARY")")")" + fi + if [[ -z "${APP_PATH:-}" && "$SEARCH_APP_NAME" != "$FALLBACK_APP_NAME" ]]; then + APP_BINARY="$( + find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/${FALLBACK_APP_NAME}.app/Contents/MacOS/${FALLBACK_APP_NAME}" -print0 \ + | xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \ + | sort -nr \ + | head -n 1 \ + | cut -d' ' -f2- + )" + if [[ -n "${APP_BINARY}" ]]; then + APP_PATH="$(dirname "$(dirname "$(dirname "$APP_BINARY")")")" + fi + fi +fi +if [[ -z "${APP_PATH:-}" || ! -d "${APP_PATH}" ]]; then + echo "${APP_NAME}.app not found in DerivedData" >&2 + exit 1 +fi + +# Staging always copies the built app and patches the plist to set an isolated +# socket path, bundle id, and display name. This prevents conflicts with the +# production cmux app. +STAGING_APP_PATH="$(dirname "$APP_PATH")/${APP_NAME}.app" +rm -rf "$STAGING_APP_PATH" +cp -R "$APP_PATH" "$STAGING_APP_PATH" +INFO_PLIST="$STAGING_APP_PATH/Contents/Info.plist" +if [[ -f "$INFO_PLIST" ]]; then + /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleName string $APP_NAME" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $APP_NAME" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$INFO_PLIST" + + # Inject staging socket paths via LSEnvironment so the Release binary + # (which defaults to /tmp/cmux.sock) uses isolated sockets instead. + STAGING_SLUG="${TAG_SLUG:-staging}" + APP_SUPPORT_DIR="$HOME/Library/Application Support/cmux" + CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-${STAGING_SLUG}.sock" + CMUX_SOCKET="/tmp/cmux-${STAGING_SLUG}.sock" + echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true + /usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXD_UNIX_PATH string \"${CMUXD_SOCKET}\"" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_PATH \"${CMUX_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST" + if [[ -S "$CMUXD_SOCKET" ]]; then + for PID in $(lsof -t "$CMUXD_SOCKET" 2>/dev/null); do + kill "$PID" 2>/dev/null || true + done + rm -f "$CMUXD_SOCKET" + fi + if [[ -S "$CMUX_SOCKET" ]]; then + rm -f "$CMUX_SOCKET" + fi + /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$STAGING_APP_PATH" >/dev/null 2>&1 || true +fi +APP_PATH="$STAGING_APP_PATH" + +# Ensure any running instance is fully terminated, regardless of DerivedData path. +/usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true +sleep 0.3 +# Kill any running staging instance; allow side-by-side with the main and dev apps. +pkill -f "${APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true +sleep 0.3 +CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd" +if [[ -d "$PWD/cmuxd" ]]; then + (cd "$PWD/cmuxd" && zig build -Doptimize=ReleaseFast) +fi +if [[ -x "$CMUXD_SRC" ]]; then + BIN_DIR="$APP_PATH/Contents/Resources/bin" + mkdir -p "$BIN_DIR" + cp "$CMUXD_SRC" "$BIN_DIR/cmuxd" + chmod +x "$BIN_DIR/cmuxd" +fi +# Avoid inheriting cmux/ghostty environment variables from the terminal that +# runs this script (often inside another cmux instance), which can cause +# socket and resource-path conflicts. +OPEN_CLEAN_ENV=( + env + -u CMUX_SOCKET_PATH + -u CMUX_TAB_ID + -u CMUX_PANEL_ID + -u CMUXD_UNIX_PATH + -u CMUX_TAG + -u CMUX_BUNDLE_ID + -u CMUX_SHELL_INTEGRATION + -u GHOSTTY_BIN_DIR + -u GHOSTTY_RESOURCES_DIR + -u GHOSTTY_SHELL_FEATURES + # Dev shells (including CI/Codex) often force-disable paging by exporting these. + # Don't leak that into cmux, otherwise `git diff` won't page even with PAGER=less. + -u GIT_PAGER + -u GH_PAGER + -u TERMINFO + -u XDG_DATA_DIRS +) + +# Always inject staging socket paths via env to ensure they take effect +# (LSEnvironment requires app restart to pick up plist changes). +"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH" +osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true + +# Safety: ensure only one instance is running. +sleep 0.2 +PIDS=($(pgrep -f "${APP_PATH}/Contents/MacOS/" || true)) +if [[ "${#PIDS[@]}" -gt 1 ]]; then + NEWEST_PID="" + NEWEST_AGE=999999 + for PID in "${PIDS[@]}"; do + AGE="$(ps -o etimes= -p "$PID" | tr -d ' ')" + if [[ -n "$AGE" && "$AGE" -lt "$NEWEST_AGE" ]]; then + NEWEST_AGE="$AGE" + NEWEST_PID="$PID" + fi + done + for PID in "${PIDS[@]}"; do + if [[ "$PID" != "$NEWEST_PID" ]]; then + kill "$PID" 2>/dev/null || true + fi + done +fi diff --git a/tests/cmux.py b/tests/cmux.py index 71b1f535..8da1d07c 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -603,6 +603,10 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def read_screen(self) -> str: + """Read the visible terminal text from the focused surface.""" + return self._send_command("read_screen") + def main(): """CLI interface for cmux""" diff --git a/tests/test_blank_screen.py b/tests/test_blank_screen.py new file mode 100644 index 00000000..ea15dd92 --- /dev/null +++ b/tests/test_blank_screen.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +Regression test for blank screen on macOS 26 (Tahoe). + +Verifies that the terminal actually renders content by: +1. Reading the screen to check for a shell prompt (non-empty) +2. Sending a command and verifying it appears on screen + +Usage: + python3 test_blank_screen.py + +Requirements: + - cmux must be running with the socket controller enabled +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError + + +class TestResult: + def __init__(self, name: str): + self.name = name + self.passed = False + self.message = "" + + def success(self, msg: str = ""): + self.passed = True + self.message = msg + + def failure(self, msg: str): + self.passed = False + self.message = msg + + +def test_screen_not_blank(client: cmux) -> TestResult: + """Test that the terminal has some visible content (shell prompt).""" + result = TestResult("Screen not blank") + try: + screen = client.read_screen() + if screen.startswith("ERROR:"): + result.failure(f"read_screen returned error: {screen}") + return result + + stripped = screen.strip() + if not stripped: + result.failure("Screen is blank — no visible content") + else: + preview = stripped[:80].replace("\n", "\\n") + result.success(f"Screen has content: {preview}...") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_render_marker(client: cmux) -> TestResult: + """Test that echoed text actually renders on screen.""" + result = TestResult("Render marker") + marker = "RENDER_TEST_MARKER_12345" + + try: + client.send(f"echo {marker}\n") + time.sleep(1.0) + + screen = client.read_screen() + if screen.startswith("ERROR:"): + result.failure(f"read_screen returned error: {screen}") + return result + + if marker in screen: + result.success(f"Marker '{marker}' found on screen") + else: + preview = screen.strip()[:200].replace("\n", "\\n") + result.failure( + f"Marker '{marker}' not found on screen. " + f"Screen content: {preview}" + ) + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def run_tests(): + print("=" * 60) + print("Blank Screen Regression Test") + print("=" * 60) + print() + + socket_path = cmux().socket_path + if not os.path.exists(socket_path): + print(f"Error: Socket not found at {socket_path}") + print("Please make sure cmux is running.") + print("Tip: set CMUX_TAG= or CMUX_SOCKET_PATH= to target a tagged instance.") + return 1 + + results = [] + + try: + with cmux() as client: + print("Testing connection...") + if not client.ping(): + print(" FAIL: Ping failed") + return 1 + print(" PASS: Connected") + print() + + print("Testing screen is not blank...") + results.append(test_screen_not_blank(client)) + status = "PASS" if results[-1].passed else "FAIL" + print(f" {status}: {results[-1].message}") + print() + + time.sleep(0.5) + + print("Testing render marker...") + results.append(test_render_marker(client)) + status = "PASS" if results[-1].passed else "FAIL" + print(f" {status}: {results[-1].message}") + print() + + except cmuxError as e: + print(f"Error: {e}") + return 1 + + print("=" * 60) + print("Results") + print("=" * 60) + + passed = sum(1 for r in results if r.passed) + total = len(results) + + for r in results: + status = "PASS" if r.passed else "FAIL" + print(f" {r.name}: {status}") + if not r.passed and r.message: + print(f" {r.message}") + + print() + print(f"Passed: {passed}/{total}") + + if passed == total: + print("\nAll tests passed!") + return 0 + else: + print(f"\n{total - passed} test(s) failed") + return 1 + + +if __name__ == "__main__": + sys.exit(run_tests())