From 9fd6d643ae3e56cba3c208738fae488f6db1a0a0 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 13 Mar 2026 17:34:51 +0800 Subject: [PATCH 1/9] Add quick terminal (quake/visor mode) support Adds a visor-style terminal window that slides in from a screen edge (or fades in at center) via a global hotkey, similar to iTerm2/Guake. Features: - Global hotkey via Carbon RegisterEventHotKey (no Accessibility needed) - Slide animation from top/bottom/left/right or fade for center - Remembers user-resized frame between show/hide cycles - Hotkey-only dismiss (no blur dismiss), matching iTerm2/Warp behavior - Returns focus to previously active app on dismiss - Cmd+W hides instead of destroying the window - Session restore tags visor window in snapshot and restores into visor - Reads config from Ghostty config files via existing GhosttyConfig parser Config keys (in ghostty config): - keybind = global:super+grave_accent=toggle_quick_terminal - quick-terminal-position = top|bottom|left|right|center - quick-terminal-animation-duration = 0.15 - quick-terminal-screen-fraction = 0.5 Co-Authored-By: Claude Opus 4.6 --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Resources/Localizable.xcstrings | 17 + Sources/AppDelegate.swift | 44 +- Sources/GhosttyConfig.swift | 20 + Sources/GhosttyTerminalView.swift | 5 + Sources/QuickTerminalController.swift | 569 ++++++++++++++++++++++++++ Sources/SessionPersistence.swift | 1 + 7 files changed, 655 insertions(+), 5 deletions(-) create mode 100644 Sources/QuickTerminalController.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 99f0407f5c80..43e021a01e48 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; + A5009901 /* QuickTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5009902 /* QuickTerminalController.swift */; }; A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; @@ -176,6 +177,7 @@ A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; A5001225 /* SocketControlSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlSettings.swift; sourceTree = ""; }; + A5009902 /* QuickTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalController.swift; sourceTree = ""; }; A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = ""; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; @@ -376,6 +378,7 @@ A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, + A5009902 /* QuickTerminalController.swift */, A5001600 /* SentryHelper.swift */, A5001620 /* AppleScriptSupport.swift */, A5001090 /* AppDelegate.swift */, @@ -649,6 +652,7 @@ A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, + A5009901 /* QuickTerminalController.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 69f4d1b8b955..cd46e09cee4a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -75066,6 +75066,23 @@ } } } + }, + "quickTerminal.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quick Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クイックターミナル" + } + } + } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d323ed40988e..b1bfa7f54563 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2240,6 +2240,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self?.observeDuplicateLaunches() } } + // Load quick terminal config early, before any window creation can + // trigger session restore (which checks visorEnabled). + installQuickTerminal() + NSWindow.allowsAutomaticWindowTabbing = false disableNativeTabbingShortcut() ensureApplicationIcon() @@ -2487,7 +2491,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } let startupSnapshot = startupSessionSnapshot - let primaryWindowSnapshot = startupSnapshot?.windows.first + + // Separate quick terminal snapshot from regular windows + let quickTerminalSnapshot = startupSnapshot?.windows.first(where: { $0.isQuickTerminal == true }) + let regularWindows = startupSnapshot?.windows.filter { $0.isQuickTerminal != true } ?? [] + + // Ensure quick terminal config is loaded (installQuickTerminal may not + // have run yet since SwiftUI window creation can precede didFinishLaunching). + QuickTerminalController.shared.loadConfiguration() + + // Stash the visor snapshot for deferred restore when the visor window + // is first created (on toggle or immediately below). + if let quickTerminalSnapshot, QuickTerminalController.shared.keybind != nil { + QuickTerminalController.shared.restoreSession(quickTerminalSnapshot) + } + + let primaryWindowSnapshot = regularWindows.first if let primaryWindowSnapshot { isApplyingStartupSessionRestore = true #if DEBUG @@ -2502,6 +2521,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent to: primaryContext, window: primaryWindow ) + } else if quickTerminalSnapshot != nil, QuickTerminalController.shared.keybind != nil { + // No regular windows to restore — hide the SwiftUI-created primary + // window and show the visor with the restored session. + primaryWindow.orderOut(nil) + DispatchQueue.main.async { + primaryWindow.close() + QuickTerminalController.shared.toggle() + } } else { let displays = currentDisplayGeometries() let fallbackGeometry = persistedWindowGeometry() @@ -2516,9 +2543,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - if let startupSnapshot { - let additionalWindows = Array(startupSnapshot - .windows + if startupSnapshot != nil { + let additionalWindows = Array(regularWindows .dropFirst() .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) #if DEBUG @@ -3392,6 +3418,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) .map { context in let window = context.window ?? windowForMainWindowId(context.windowId) + let isQuickTerminal = context.windowId == QuickTerminalController.shared.windowId return SessionWindowSnapshot( frame: window.map { SessionRectSnapshot($0.frame) }, display: displaySnapshot(for: window), @@ -3400,7 +3427,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isVisible: context.sidebarState.isVisible, selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) - ) + ), + isQuickTerminal: isQuickTerminal ? true : nil ) } @@ -7669,6 +7697,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func installQuickTerminal() { + let controller = QuickTerminalController.shared + controller.loadConfiguration() + controller.installGlobalHotkey() + } + private func installGhosttyConfigObserver() { guard ghosttyConfigObserver == nil else { return } ghosttyConfigObserver = NotificationCenter.default.addObserver( diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 13f78f1257dd..7b302ec2f484 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -20,6 +20,12 @@ struct GhosttyConfig { var unfocusedSplitFill: NSColor? var splitDividerColor: NSColor? + // Quick terminal settings + var quickTerminalKeybindRaw: String? + var quickTerminalPosition: String? + var quickTerminalAnimationDuration: Double? + var quickTerminalScreenFraction: Double? + // Colors (from theme or config) var backgroundColor: NSColor = NSColor(hex: "#272822")! var backgroundOpacity: Double = 1.0 @@ -304,6 +310,20 @@ struct GhosttyConfig { if let opacity = Double(value) { sidebarTintOpacity = min(max(opacity, 0), 1) } + case "keybind": + if value.contains("toggle_quick_terminal") { + quickTerminalKeybindRaw = value + } + case "quick-terminal-position": + quickTerminalPosition = value + case "quick-terminal-animation-duration": + if let dur = Double(value) { + quickTerminalAnimationDuration = dur + } + case "quick-terminal-screen-fraction": + if let frac = Double(value) { + quickTerminalScreenFraction = frac + } default: break } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cc49002b1566..5478dc8348fd 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2401,6 +2401,11 @@ class GhosttyApp { } } } + case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: + return performOnMain { + QuickTerminalController.shared.toggle() + return true + } default: return false } diff --git a/Sources/QuickTerminalController.swift b/Sources/QuickTerminalController.swift new file mode 100644 index 000000000000..1ad41d723c6f --- /dev/null +++ b/Sources/QuickTerminalController.swift @@ -0,0 +1,569 @@ +import AppKit +import Carbon +import SwiftUI + +/// Manages the quick terminal (quake/visor mode) window. +/// +/// The quick terminal is a floating, borderless window that slides down from +/// the top of the screen (or another configured edge) when toggled via a global +/// hotkey. Unlike Ghostty's quick terminal, cmux's version hosts a full +/// ContentView with sidebar and tabs. +@MainActor +final class QuickTerminalController: NSObject, NSWindowDelegate { + + // MARK: - Types + + enum Position: String, Sendable { + case top + case bottom + case left + case right + case center + } + + struct QuickTerminalKeybind: Equatable, Sendable { + var keyCode: UInt16? + var characters: String? + var commandModifier: Bool + var shiftModifier: Bool + var optionModifier: Bool + var controlModifier: Bool + + var modifierFlags: NSEvent.ModifierFlags { + var flags: NSEvent.ModifierFlags = [] + if commandModifier { flags.insert(.command) } + if shiftModifier { flags.insert(.shift) } + if optionModifier { flags.insert(.option) } + if controlModifier { flags.insert(.control) } + return flags + } + } + + // MARK: - Configuration + + private(set) var position: Position = .top + private(set) var animationDuration: TimeInterval = 0.15 + private(set) var screenFraction: CGFloat = 0.5 + + // MARK: - State + + private var window: NSWindow? + private var tabManager: TabManager? + private var sidebarState: SidebarState? + private var sidebarSelectionState: SidebarSelectionState? + private var isVisible = false + private var isAnimating = false + private var lastFrame: NSRect? + private(set) var windowId: UUID? + + // MARK: - Global Hotkey + + private var carbonHotkeyRef: EventHotKeyRef? + private var carbonHandlerRef: EventHandlerRef? + private var localMonitor: Any? + + /// The keybind parsed from Ghostty config (e.g. "super+grave_accent"). + /// When nil, the quick terminal feature is disabled. + private(set) var keybind: QuickTerminalKeybind? + + // MARK: - Singleton + + static let shared = QuickTerminalController() + private override init() { + super.init() + } + + // MARK: - Setup + + private var didLoadConfiguration = false + + /// Load quick terminal settings from the Ghostty config files via + /// the shared `GhosttyConfig` parser (same file search order and + /// parsing logic used for all other Ghostty settings in cmux). + func loadConfiguration() { + guard !didLoadConfiguration else { return } + didLoadConfiguration = true + + let config = GhosttyConfig.load(useCache: false) + + if let raw = config.quickTerminalKeybindRaw { + keybind = Self.parseKeybind(raw) + } + if let p = config.quickTerminalPosition, let pos = Position(rawValue: p) { + position = pos + } + if let d = config.quickTerminalAnimationDuration { + animationDuration = d + } + if let f = config.quickTerminalScreenFraction { + screenFraction = max(0.1, min(1.0, CGFloat(f))) + } + } + + /// Parse a Ghostty keybind line like "global:super+grave_accent=toggle_quick_terminal". + nonisolated static func parseKeybind(_ value: String) -> QuickTerminalKeybind? { + // Must end with toggle_quick_terminal action + let bindParts = value.split(separator: "=", maxSplits: 1) + guard bindParts.count == 2 else { return nil } + let action = bindParts[1].trimmingCharacters(in: .whitespaces) + guard action == "toggle_quick_terminal" else { return nil } + + var keyPart = bindParts[0].trimmingCharacters(in: .whitespaces) + + // Must be a global keybind + guard keyPart.hasPrefix("global:") else { return nil } + keyPart = String(keyPart.dropFirst("global:".count)) + + // Parse modifier+key combinations + let tokens = keyPart.split(separator: "+").map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + guard !tokens.isEmpty else { return nil } + + var hasCommand = false + var hasShift = false + var hasOption = false + var hasControl = false + var keyName: String? + + for token in tokens { + switch token { + case "super", "cmd", "command": + hasCommand = true + case "shift": + hasShift = true + case "alt", "opt", "option": + hasOption = true + case "ctrl", "control": + hasControl = true + default: + keyName = token + } + } + + guard let key = keyName else { return nil } + + return QuickTerminalKeybind( + keyCode: ghosttyKeyNameToKeyCode(key), + characters: ghosttyKeyNameToCharacters(key), + commandModifier: hasCommand, + shiftModifier: hasShift, + optionModifier: hasOption, + controlModifier: hasControl + ) + } + + nonisolated private static func ghosttyKeyNameToKeyCode(_ name: String) -> UInt16? { + switch name { + case "grave_accent", "`": return 50 + case "a": return 0 + case "b": return 11 + case "c": return 8 + case "d": return 2 + case "e": return 14 + case "f": return 3 + case "g": return 5 + case "h": return 4 + case "i": return 34 + case "j": return 38 + case "k": return 40 + case "l": return 37 + case "m": return 46 + case "n": return 45 + case "o": return 31 + case "p": return 35 + case "q": return 12 + case "r": return 15 + case "s": return 1 + case "t": return 17 + case "u": return 32 + case "v": return 9 + case "w": return 13 + case "x": return 7 + case "y": return 16 + case "z": return 6 + case "space": return 49 + case "tab": return 48 + case "return", "enter": return 36 + case "escape": return 53 + case "minus", "-": return 27 + case "equal", "=": return 24 + case "left_bracket", "[": return 33 + case "right_bracket", "]": return 30 + case "backslash", "\\": return 42 + case "semicolon", ";": return 41 + case "apostrophe", "'": return 39 + case "comma", ",": return 43 + case "period", ".": return 47 + case "slash", "/": return 44 + default: return nil + } + } + + nonisolated private static func ghosttyKeyNameToCharacters(_ name: String) -> String? { + switch name { + case "grave_accent", "`": return "`" + case "space": return " " + case "tab": return "\t" + case "return", "enter": return "\r" + default: + if name.count == 1 { return name } + return nil + } + } + + // MARK: - Event Matching (nonisolated for use in monitor callbacks) + + nonisolated private static func eventMatchesKeybind(_ event: NSEvent, _ bind: QuickTerminalKeybind) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + + guard flags == bind.modifierFlags else { return false } + + if let expectedCode = bind.keyCode { + return event.keyCode == expectedCode + } + if let expectedChars = bind.characters { + return event.charactersIgnoringModifiers?.lowercased() == expectedChars + } + return false + } + + // MARK: - Global Hotkey Management + + /// Carbon modifier flags from our keybind. + nonisolated private static func carbonModifiers(for bind: QuickTerminalKeybind) -> UInt32 { + var mods: UInt32 = 0 + if bind.commandModifier { mods |= UInt32(cmdKey) } + if bind.shiftModifier { mods |= UInt32(shiftKey) } + if bind.optionModifier { mods |= UInt32(optionKey) } + if bind.controlModifier { mods |= UInt32(controlKey) } + return mods + } + + func installGlobalHotkey() { + guard let bind = keybind, let keyCode = bind.keyCode else { return } + + // Register a Carbon global hotkey — works system-wide without Accessibility permissions. + let hotkeyID = EventHotKeyID(signature: OSType(0x636D7578), id: 1) // "cmux" + let modifiers = Self.carbonModifiers(for: bind) + + var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + let handlerCallback: EventHandlerUPP = { _, event, _ -> OSStatus in + DispatchQueue.main.async { + QuickTerminalController.shared.toggle() + } + return noErr + } + + var handlerRef: EventHandlerRef? + InstallEventHandler( + GetApplicationEventTarget(), + handlerCallback, + 1, + &eventType, + nil, + &handlerRef + ) + carbonHandlerRef = handlerRef + + var hotkeyRef: EventHotKeyRef? + RegisterEventHotKey( + UInt32(keyCode), + modifiers, + hotkeyID, + GetApplicationEventTarget(), + 0, + &hotkeyRef + ) + carbonHotkeyRef = hotkeyRef + + // Local monitor: catches the hotkey when the app IS active (Carbon global + // hotkeys don't fire for the owning app's own key events). + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if Self.eventMatchesKeybind(event, bind) { + Self.shared.toggle() + return nil + } + return event + } + } + + func removeGlobalHotkey() { + if let ref = carbonHotkeyRef { + UnregisterEventHotKey(ref) + carbonHotkeyRef = nil + } + if let ref = carbonHandlerRef { + RemoveEventHandler(ref) + carbonHandlerRef = nil + } + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + + // MARK: - Toggle + + func toggle() { + guard !isAnimating else { return } + + if isVisible { + hide() + } else { + show() + } + } + + // MARK: - Show + + private func show() { + let win = window ?? createQuickTerminalWindow() + window = win + + guard let screen = NSScreen.main ?? NSScreen.screens.first else { return } + let visibleFrame = screen.visibleFrame + let targetFrame = lastFrame ?? quickTerminalFrame(in: visibleFrame) + + // Set initial off-screen frame for slide animation + var startFrame = targetFrame + switch position { + case .top: + startFrame.origin.y = visibleFrame.maxY + case .bottom: + startFrame.origin.y = visibleFrame.minY - targetFrame.height + case .left: + startFrame.origin.x = visibleFrame.minX - targetFrame.width + case .right: + startFrame.origin.x = visibleFrame.maxX + case .center: + break + } + + win.setFrame(startFrame, display: false) + win.alphaValue = position == .center ? 0 : 1 + win.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + win.makeKey() + + isAnimating = true + NSAnimationContext.runAnimationGroup({ context in + context.duration = animationDuration + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + win.animator().setFrame(targetFrame, display: true) + if position == .center { + win.animator().alphaValue = 1 + } + }, completionHandler: { [weak self] in + self?.isAnimating = false + self?.isVisible = true + }) + } + + // MARK: - Hide + + private func hide() { + guard let win = window else { return } + guard let screen = NSScreen.main ?? NSScreen.screens.first else { return } + let visibleFrame = screen.visibleFrame + let currentFrame = win.frame + lastFrame = currentFrame + + var endFrame = currentFrame + switch position { + case .top: + endFrame.origin.y = visibleFrame.maxY + case .bottom: + endFrame.origin.y = visibleFrame.minY - currentFrame.height + case .left: + endFrame.origin.x = visibleFrame.minX - currentFrame.width + case .right: + endFrame.origin.x = visibleFrame.maxX + case .center: + break + } + + isAnimating = true + // Use a slightly faster hide than show for snappier feel + let hideDuration = animationDuration * 0.7 + NSAnimationContext.runAnimationGroup({ context in + context.duration = hideDuration + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + win.animator().setFrame(endFrame, display: true) + win.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + win.orderOut(nil) + win.alphaValue = 1 // reset for next show + self?.isAnimating = false + self?.isVisible = false + + // Give focus back to the previously active app. Check if cmux + // has any other visible main windows; if not, deactivate entirely. + DispatchQueue.main.async { + let hasOtherVisibleWindow = NSApp.windows.contains { w in + w !== win && w.isVisible && !w.isMiniaturized + && w.windowNumber > 0 + && (w.styleMask.contains(.titled) || w.styleMask.contains(.fullSizeContentView)) + } + if !hasOtherVisibleWindow { + NSApp.hide(nil) + } + } + }) + } + + // MARK: - NSWindowDelegate + + nonisolated func windowShouldClose(_ sender: NSWindow) -> Bool { + MainActor.assumeIsolated { + if isVisible, !isAnimating { + hide() + } + } + return false + } + + // MARK: - Window Creation + + private func createQuickTerminalWindow() -> NSWindow { + let manager = TabManager() + self.tabManager = manager + + let sidebarState = SidebarState(isVisible: true) + self.sidebarState = sidebarState + let sidebarSelectionState = SidebarSelectionState(selection: .tabs) + self.sidebarSelectionState = sidebarSelectionState + let notificationStore = TerminalNotificationStore.shared + + // Apply pending session snapshot BEFORE creating the ContentView so + // SwiftUI initializes with the restored workspaces, not empty state. + if let snapshot = pendingSessionSnapshot { + pendingSessionSnapshot = nil + manager.restoreSessionSnapshot(snapshot.tabManager) + sidebarState.isVisible = snapshot.sidebar.isVisible + sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + ) + sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection + } + + let wId = UUID() + self.windowId = wId + + let root = ContentView( + updateViewModel: AppDelegate.shared?.updateViewModel ?? UpdateViewModel(), + windowId: wId + ) + .environmentObject(manager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = String(localized: "quickTerminal.title", defaultValue: "Quick Terminal") + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = false + window.isMovable = false + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.hasShadow = true + window.identifier = NSUserInterfaceItemIdentifier("cmux.quickTerminal") + window.contentView = NSHostingView(rootView: root) + window.delegate = self + + // Safety net: if the window is somehow destroyed despite windowShouldClose, + // reset state so toggle() will recreate it. + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.window = nil + self.tabManager = nil + self.sidebarState = nil + self.sidebarSelectionState = nil + self.isVisible = false + } + + // Register with AppDelegate so terminal surfaces work properly + if let appDelegate = AppDelegate.shared { + appDelegate.registerMainWindow( + window, + windowId: wId, + tabManager: manager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + appDelegate.applyWindowDecorations(to: window) + } + + return window + } + + // MARK: - Frame Calculation + + private func quickTerminalFrame(in visibleFrame: NSRect) -> NSRect { + switch position { + case .top: + let height = visibleFrame.height * screenFraction + return NSRect( + x: visibleFrame.minX, + y: visibleFrame.maxY - height, + width: visibleFrame.width, + height: height + ) + case .bottom: + let height = visibleFrame.height * screenFraction + return NSRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: visibleFrame.width, + height: height + ) + case .left: + let width = visibleFrame.width * screenFraction + return NSRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: width, + height: visibleFrame.height + ) + case .right: + let width = visibleFrame.width * screenFraction + return NSRect( + x: visibleFrame.maxX - width, + y: visibleFrame.minY, + width: width, + height: visibleFrame.height + ) + case .center: + let width = visibleFrame.width * 0.8 + let height = visibleFrame.height * 0.8 + return NSRect( + x: visibleFrame.midX - width / 2, + y: visibleFrame.midY - height / 2, + width: width, + height: height + ) + } + } + + // MARK: - Session Restore + + /// Stash a session snapshot to be applied when the visor window is first created. + private var pendingSessionSnapshot: SessionWindowSnapshot? + + /// Queue a session snapshot for deferred restore. + /// The snapshot is applied lazily in `createQuickTerminalWindow()` so we + /// don't create an AppKit window during startup restore (which would + /// re-enter `registerMainWindow` / `attemptStartupSessionRestoreIfNeeded`). + func restoreSession(_ snapshot: SessionWindowSnapshot) { + pendingSessionSnapshot = snapshot + } +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 5419cd92b283..5d94e11f3718 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -351,6 +351,7 @@ struct SessionWindowSnapshot: Codable, Sendable { var display: SessionDisplaySnapshot? var tabManager: SessionTabManagerSnapshot var sidebar: SessionSidebarSnapshot + var isQuickTerminal: Bool? } struct AppSessionSnapshot: Codable, Sendable { From 5df34e1fb247efda64affe0c3c453007a7471320 Mon Sep 17 00:00:00 2001 From: Samuel Steady Date: Sun, 15 Mar 2026 20:36:19 +0800 Subject: [PATCH 2/9] fix backtick hotkey registration --- Sources/QuickTerminalController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/QuickTerminalController.swift b/Sources/QuickTerminalController.swift index 1ad41d723c6f..199b328114a5 100644 --- a/Sources/QuickTerminalController.swift +++ b/Sources/QuickTerminalController.swift @@ -153,7 +153,7 @@ final class QuickTerminalController: NSObject, NSWindowDelegate { nonisolated private static func ghosttyKeyNameToKeyCode(_ name: String) -> UInt16? { switch name { - case "grave_accent", "`": return 50 + case "grave_accent", "backquote", "`": return 50 case "a": return 0 case "b": return 11 case "c": return 8 @@ -200,7 +200,7 @@ final class QuickTerminalController: NSObject, NSWindowDelegate { nonisolated private static func ghosttyKeyNameToCharacters(_ name: String) -> String? { switch name { - case "grave_accent", "`": return "`" + case "grave_accent", "backquote", "`": return "`" case "space": return " " case "tab": return "\t" case "return", "enter": return "\r" From f0810fa5d1de9d911a0584854e76f986604a33ef Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 16 Mar 2026 22:23:14 +0800 Subject: [PATCH 3/9] Address review feedback: geometry persistence and keybind parsing - Skip quick terminal window when persisting fallback window geometry so regular windows don't reopen with the visor's edge-attached frame - Tighten keybind pre-filter to exact action match instead of substring Co-Authored-By: Claude Opus 4.6 --- Sources/AppDelegate.swift | 2 +- Sources/GhosttyConfig.swift | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b1bfa7f54563..328d0f24e619 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3176,7 +3176,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in + let persistedGeometryData = snapshot.windows.first(where: { $0.isQuickTerminal != true }).flatMap { primaryWindow in Self.encodedPersistedWindowGeometryData( frame: primaryWindow.frame, display: primaryWindow.display diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 7b302ec2f484..b99a5f9fecc6 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -311,7 +311,9 @@ struct GhosttyConfig { sidebarTintOpacity = min(max(opacity, 0), 1) } case "keybind": - if value.contains("toggle_quick_terminal") { + let action = value.split(separator: "=", maxSplits: 1).last? + .trimmingCharacters(in: .whitespaces) + if action == "toggle_quick_terminal" { quickTerminalKeybindRaw = value } case "quick-terminal-position": From 1f1bb9956207d7d585aaeb861b38310ff9555ab6 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 17 Mar 2026 08:09:00 +0800 Subject: [PATCH 4/9] Restore visor workspaces as regular windows when hotkey is disabled When the quick terminal hotkey is removed from config, visor workspaces are now treated as regular windows during session restore instead of being silently dropped. Co-Authored-By: Claude Opus 4.6 --- Sources/AppDelegate.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 328d0f24e619..24ff4247bd1e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2492,17 +2492,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let startupSnapshot = startupSessionSnapshot - // Separate quick terminal snapshot from regular windows - let quickTerminalSnapshot = startupSnapshot?.windows.first(where: { $0.isQuickTerminal == true }) - let regularWindows = startupSnapshot?.windows.filter { $0.isQuickTerminal != true } ?? [] - // Ensure quick terminal config is loaded (installQuickTerminal may not // have run yet since SwiftUI window creation can precede didFinishLaunching). QuickTerminalController.shared.loadConfiguration() + // Separate quick terminal snapshot from regular windows. If the visor + // hotkey is no longer configured, treat the visor snapshot as a regular + // window so its workspaces aren't silently dropped. + let visorEnabled = QuickTerminalController.shared.keybind != nil + let quickTerminalSnapshot = visorEnabled + ? startupSnapshot?.windows.first(where: { $0.isQuickTerminal == true }) + : nil + let regularWindows = startupSnapshot?.windows.filter { window in + if visorEnabled { return window.isQuickTerminal != true } + return true + } ?? [] + // Stash the visor snapshot for deferred restore when the visor window // is first created (on toggle or immediately below). - if let quickTerminalSnapshot, QuickTerminalController.shared.keybind != nil { + if let quickTerminalSnapshot { QuickTerminalController.shared.restoreSession(quickTerminalSnapshot) } From a5fb47edcaa2487abdfaf61a417dcbf747dbe4d5 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 17 Apr 2026 09:20:52 +0900 Subject: [PATCH 5/9] Preserve quick terminal session across crash restore --- Sources/AppDelegate.swift | 24 +++++++++++++++-- Sources/QuickTerminalController.swift | 8 ++++++ cmuxTests/SessionPersistenceTests.swift | 35 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 24ff4247bd1e..7eb9d5d053ff 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3440,14 +3440,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - guard !windows.isEmpty else { return nil } + let windowsWithPendingQuickTerminal = Self.includingPendingQuickTerminalSnapshot( + windows, + pendingQuickTerminalSnapshot: QuickTerminalController.shared.pendingSessionSnapshotForPersistence() + ) + + guard !windowsWithPendingQuickTerminal.isEmpty else { return nil } return AppSessionSnapshot( version: SessionSnapshotSchema.currentVersion, createdAt: Date().timeIntervalSince1970, - windows: windows + windows: windowsWithPendingQuickTerminal ) } + nonisolated static func includingPendingQuickTerminalSnapshot( + _ windows: [SessionWindowSnapshot], + pendingQuickTerminalSnapshot: SessionWindowSnapshot? + ) -> [SessionWindowSnapshot] { + guard let pendingQuickTerminalSnapshot else { return windows } + guard !windows.contains(where: { $0.isQuickTerminal == true }) else { return windows } + + var merged = windows + if merged.count >= SessionPersistencePolicy.maxWindowsPerSnapshot { + merged.removeLast() + } + merged.append(pendingQuickTerminalSnapshot) + return merged + } + #if DEBUG private func debugLogSessionSaveSnapshot( _ snapshot: AppSessionSnapshot, diff --git a/Sources/QuickTerminalController.swift b/Sources/QuickTerminalController.swift index 199b328114a5..425ed39bb2cd 100644 --- a/Sources/QuickTerminalController.swift +++ b/Sources/QuickTerminalController.swift @@ -566,4 +566,12 @@ final class QuickTerminalController: NSObject, NSWindowDelegate { func restoreSession(_ snapshot: SessionWindowSnapshot) { pendingSessionSnapshot = snapshot } + + /// Return a queued quick-terminal snapshot when the visor hasn't been + /// recreated yet in the current process. Autosave must carry this forward + /// or a crash/force-quit will overwrite the last good visor state. + func pendingSessionSnapshotForPersistence() -> SessionWindowSnapshot? { + guard window == nil else { return nil } + return pendingSessionSnapshot + } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index af9ccf2d4570..c2da19b1f6ba 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -399,6 +399,41 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testIncludingPendingQuickTerminalSnapshotPreservesDeferredVisorState() { + let primaryWindow = makeSnapshot(version: SessionSnapshotSchema.currentVersion).windows[0] + var pendingQuickTerminal = primaryWindow + pendingQuickTerminal.isQuickTerminal = true + pendingQuickTerminal.sidebar.isVisible = false + pendingQuickTerminal.tabManager.workspaces[0].processTitle = "visor-workspace" + + let merged = AppDelegate.includingPendingQuickTerminalSnapshot( + [primaryWindow], + pendingQuickTerminalSnapshot: pendingQuickTerminal + ) + + XCTAssertEqual(merged.count, 2) + XCTAssertEqual(merged.last?.isQuickTerminal, true) + XCTAssertEqual(merged.last?.sidebar.isVisible, false) + XCTAssertEqual(merged.last?.tabManager.workspaces.first?.processTitle, "visor-workspace") + } + + func testIncludingPendingQuickTerminalSnapshotDoesNotDuplicateExistingQuickTerminal() { + let primaryWindow = makeSnapshot(version: SessionSnapshotSchema.currentVersion).windows[0] + var existingQuickTerminal = primaryWindow + existingQuickTerminal.isQuickTerminal = true + var pendingQuickTerminal = primaryWindow + pendingQuickTerminal.isQuickTerminal = true + pendingQuickTerminal.tabManager.workspaces[0].processTitle = "pending-visor" + + let merged = AppDelegate.includingPendingQuickTerminalSnapshot( + [primaryWindow, existingQuickTerminal], + pendingQuickTerminalSnapshot: pendingQuickTerminal + ) + + XCTAssertEqual(merged.count, 2) + XCTAssertEqual(merged.last?.tabManager.workspaces.first?.processTitle, primaryWindow.tabManager.workspaces.first?.processTitle) + } + func testUnchangedAutosaveFingerprintSkipsWithinStalenessWindow() { let now = Date() XCTAssertTrue( From 1f93ac850a96fbb01edd525a74f681b8cc359941 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 9 May 2026 08:06:02 +0800 Subject: [PATCH 6/9] Harden session-snapshot persistence against logout and crash overwrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related defenses for visor and main-window session restore: - Set isTerminatingApp synchronously inside the willPowerOff observer so SwiftUI's WindowGroup teardown during logout/restart sees the flag and skips the unregister-time removeWhenEmpty save that was wiping the snapshot before applicationShouldTerminate could persist it. - Add a trivial-overwrite shield in persistSessionSnapshot: once a non-trivial snapshot has been persisted, refuse to overwrite (or remove) it with a snapshot that looks like a fresh launch (≤1 window with ≤1 default workspace and no custom state). The cache is seeded from the on-disk snapshot at startup and updated after each successful write. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/AppDelegate.swift | 60 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7eb9d5d053ff..9243eb99c8fd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2064,6 +2064,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false + /// Tracks whether the last successfully persisted snapshot had real content, + /// so a subsequent "trivial" snapshot (1 default workspace, no state) can't + /// silently wipe a richer prior session after a hard crash. + private var lastPersistedSnapshotIsNonTrivial = false private var socketListenerHealthTimer: DispatchSourceTimer? private var socketListenerHealthCheckInFlight = false private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) @@ -2423,7 +2427,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !didPrepareStartupSessionSnapshot else { return } didPrepareStartupSessionSnapshot = true guard SessionRestorePolicy.shouldAttemptRestore() else { return } - startupSessionSnapshot = SessionPersistenceStore.load() + let loaded = SessionPersistenceStore.load() + startupSessionSnapshot = loaded + lastPersistedSnapshotIsNonTrivial = !Self.isTrivialSnapshot(loaded) } private func persistedWindowGeometry( @@ -2958,9 +2964,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in + // Set synchronously so SwiftUI's WindowGroup teardown during logout + // sees isTerminatingApp=true and skips the unregister-time + // removeWhenEmpty save that would otherwise wipe the snapshot. + self?.isTerminatingApp = true Task { @MainActor [weak self] in guard let self else { return } - self.isTerminatingApp = true _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } } @@ -3207,6 +3216,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !isTerminatingApp } + /// A snapshot is "trivial" when it looks indistinguishable from a fresh + /// launch: at most one window, with at most one workspace, with no custom + /// state. We use this as the no-overwrite shield so a hard crash that + /// briefly leaves cmux in default state can't wipe a richer prior session. + nonisolated static func isTrivialSnapshot(_ snapshot: AppSessionSnapshot?) -> Bool { + guard let snapshot else { return true } + guard snapshot.windows.count <= 1 else { return false } + guard let window = snapshot.windows.first else { return true } + return isTrivialWindow(window) + } + + nonisolated static func isTrivialWindow(_ window: SessionWindowSnapshot) -> Bool { + let workspaces = window.tabManager.workspaces + guard workspaces.count <= 1 else { return false } + guard let workspace = workspaces.first else { return true } + return isTrivialWorkspace(workspace) + } + + nonisolated static func isTrivialWorkspace(_ workspace: SessionWorkspaceSnapshot) -> Bool { + if workspace.panels.count > 1 { return false } + if let title = workspace.customTitle, !title.isEmpty { return false } + if workspace.customColor != nil { return false } + if workspace.isPinned { return false } + if !workspace.statusEntries.isEmpty { return false } + if !workspace.logEntries.isEmpty { return false } + if workspace.progress != nil { return false } + if workspace.gitBranch != nil { return false } + return true + } + nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( isTerminatingApp: Bool ) -> Bool { @@ -3387,6 +3426,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + let willPersistSnapshot = snapshot != nil || removeWhenEmpty + let newIsTrivial = Self.isTrivialSnapshot(snapshot) + let blockSnapshotPersist = willPersistSnapshot + && newIsTrivial + && lastPersistedSnapshotIsNonTrivial + +#if DEBUG + if blockSnapshotPersist { + dlog("session.save.skipped reason=trivial_overwrite_protect removeWhenEmpty=\(removeWhenEmpty ? 1 : 0)") + } +#endif + let writeBlock = { if let persistedGeometryData { UserDefaults.standard.set( @@ -3394,6 +3445,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent forKey: Self.persistedWindowGeometryDefaultsKey ) } + if blockSnapshotPersist { return } if let snapshot { _ = SessionPersistenceStore.save(snapshot) } else if removeWhenEmpty { @@ -3406,6 +3458,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { sessionPersistenceQueue.async(execute: writeBlock) } + + if willPersistSnapshot && !blockSnapshotPersist { + lastPersistedSnapshotIsNonTrivial = !newIsTrivial + } } private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { From 994cdec36a4190aa3098cece656009c156afdcbc Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 15 May 2026 09:17:35 +0800 Subject: [PATCH 7/9] Inject FileExplorerState and CmuxConfigStore into visor ContentView Upstream main added @EnvironmentObject FileExplorerState and CmuxConfigStore to ContentView. QuickTerminalController's NSHostingView only injected the four older env objects, so opening the visor crashed with SIGILL the moment ContentView.body tried to read fileExplorerState. Create both objects (wiring CmuxConfigStore to the visor's TabManager and calling loadAll(), matching createMainWindow), inject them via .environmentObject, pass them to registerMainWindow, and clear them on willClose so they don't outlive the visor window. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/QuickTerminalController.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/QuickTerminalController.swift b/Sources/QuickTerminalController.swift index 425ed39bb2cd..f95b30ec0d09 100644 --- a/Sources/QuickTerminalController.swift +++ b/Sources/QuickTerminalController.swift @@ -51,6 +51,8 @@ final class QuickTerminalController: NSObject, NSWindowDelegate { private var tabManager: TabManager? private var sidebarState: SidebarState? private var sidebarSelectionState: SidebarSelectionState? + private var fileExplorerState: FileExplorerState? + private var cmuxConfigStore: CmuxConfigStore? private var isVisible = false private var isAnimating = false private var lastFrame: NSRect? @@ -433,6 +435,12 @@ final class QuickTerminalController: NSObject, NSWindowDelegate { let sidebarSelectionState = SidebarSelectionState(selection: .tabs) self.sidebarSelectionState = sidebarSelectionState let notificationStore = TerminalNotificationStore.shared + let fileExplorerState = FileExplorerState() + self.fileExplorerState = fileExplorerState + let cmuxConfigStore = CmuxConfigStore() + cmuxConfigStore.wireDirectoryTracking(tabManager: manager) + cmuxConfigStore.loadAll() + self.cmuxConfigStore = cmuxConfigStore // Apply pending session snapshot BEFORE creating the ContentView so // SwiftUI initializes with the restored workspaces, not empty state. @@ -457,6 +465,8 @@ final class QuickTerminalController: NSObject, NSWindowDelegate { .environmentObject(notificationStore) .environmentObject(sidebarState) .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) + .environmentObject(cmuxConfigStore) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), @@ -488,6 +498,8 @@ final class QuickTerminalController: NSObject, NSWindowDelegate { self.tabManager = nil self.sidebarState = nil self.sidebarSelectionState = nil + self.fileExplorerState = nil + self.cmuxConfigStore = nil self.isVisible = false } @@ -498,7 +510,9 @@ final class QuickTerminalController: NSObject, NSWindowDelegate { windowId: wId, tabManager: manager, sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState + sidebarSelectionState: sidebarSelectionState, + fileExplorerState: fileExplorerState, + cmuxConfigStore: cmuxConfigStore ) appDelegate.applyWindowDecorations(to: window) } From f8f875ca8a8b1e37160debb810a63dbdf395cfe8 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 15 May 2026 09:27:09 +0800 Subject: [PATCH 8/9] Restore ad-hoc Release signing by dropping keychain-access-groups entitlement Upstream main wired the Release config to Resources/cmux.entitlements, which declares keychain-access-groups. codesign refuses that entitlement under ad-hoc signing because it requires the team prefix to be validated, so local Release builds (and any machine without a development cert) failed with "entitlements that require signing with a development certificate". Clear the Release config's CODE_SIGN_ENTITLEMENTS and remove the file. The auth code's FallbackTokenStore already falls back to the file store when keychain writes fail, so removing the shared keychain group has no runtime impact on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- GhosttyTabs.xcodeproj/project.pbxproj | 2 +- Resources/cmux.entitlements | 11 ----------- Sources/Auth/AuthManager.swift | 9 +++------ 3 files changed, 4 insertions(+), 18 deletions(-) delete mode 100644 Resources/cmux.entitlements diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 535e59778067..946620582418 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -2456,7 +2456,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CMUX_AUTH_CALLBACK_SCHEME = cmux; - CODE_SIGN_ENTITLEMENTS = Resources/cmux.entitlements; + CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 86; diff --git a/Resources/cmux.entitlements b/Resources/cmux.entitlements deleted file mode 100644 index 02d841c2da10..000000000000 --- a/Resources/cmux.entitlements +++ /dev/null @@ -1,11 +0,0 @@ - - - - - keychain-access-groups - - $(AppIdentifierPrefix)$(CFBundleIdentifier) - $(CFBundleIdentifier) - - - diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 842940a29404..97fb58a3bd74 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -141,12 +141,9 @@ final class AuthManager: ObservableObject { static let shared = AuthManager(tokenStore: AuthManager.defaultTokenStore()) private static func defaultTokenStore() -> any StackAuthTokenStoreProtocol { - // Release builds include a keychain-access-groups entitlement (via - // Resources/cmux.entitlements) and go through the data-protection - // keychain. Debug ad-hoc builds can't carry that entitlement - // without a provisioning profile, so Keychain writes fail with - // errSecMissingEntitlement and the file store takes over. The - // wrapper picks per-run based on the first keychain write result. + // Ad-hoc-signed builds (Debug and this branch's Release) can't carry a + // keychain-access-groups entitlement, so Keychain writes fail with + // errSecMissingEntitlement and the file store takes over. return FallbackTokenStore( primary: KeychainStackTokenStore(), fallback: FileStackTokenStore() From 39f4c581dd7013a98505902d12bbda9d8e8b5dac Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 15 May 2026 09:29:41 +0800 Subject: [PATCH 9/9] Document personal-fork build rules and visor env-object requirement Add a "Personal fork notes (read first)" section to CLAUDE.md covering: - Why the visor needs FileExplorerState + CmuxConfigStore injected (and what to do when upstream adds more @EnvironmentObjects to ContentView) - Why Resources/cmux.entitlements must stay deleted and the Release config's CODE_SIGN_ENTITLEMENTS must stay empty (ad-hoc-only signing) - Fresh-machine clone/build/install sequence Add a one-paragraph personal-fork notice at the top of README.md pointing at the CLAUDE.md section. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 43 +++++++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 45 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b0da420985cd..c3ea96eadeab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,48 @@ # cmux agent notes +## Personal fork notes (read first) + +This repo (`TraderSamwise/cmux`) is **sam's personal fork** of [`manaflow-ai/cmux`](https://github.com/manaflow-ai/cmux). The long-lived feature branch is `feature/quick-terminal-pr`. Two things to know before building or merging from upstream: + +### Quick terminal (visor / quake-mode) + +The fork adds a quick-terminal panel implemented in `Sources/QuickTerminalController.swift`. It registers as a `mainWindowContext` just like a regular main window and runs the same `ContentView`. When merging from upstream `main`: + +- If a new `@EnvironmentObject` is added to `ContentView` (`Sources/ContentView.swift` ~L1051), mirror the injection in `QuickTerminalController.createQuickTerminalWindow` and pass it through `registerMainWindow(...)`. Otherwise the visor will SIGILL the instant SwiftUI evaluates the body. +- Current required env objects: `TabManager`, `TerminalNotificationStore`, `SidebarState`, `SidebarSelectionState`, `FileExplorerState`, `CmuxConfigStore`. + +### Ad-hoc Release signing only + +This fork is built only with **ad-hoc signing** — no Apple Developer cert, no provisioning profile. To keep that working: + +- `Resources/cmux.entitlements` (added upstream in PR #3027 for auth scaffolding) has been **intentionally deleted**. Do not reintroduce it: `keychain-access-groups` cannot be carried by an ad-hoc signature, and codesign refuses the build with *"entitlements that require signing with a development certificate"*. +- The Release config's `CODE_SIGN_ENTITLEMENTS` is set to `""` (see `GhosttyTabs.xcodeproj/project.pbxproj`). Keep it that way. +- The auth code's `FallbackTokenStore` (`Sources/Auth/AuthManager.swift`) already drops to a file store when keychain writes fail, so dropping the entitlement has no runtime impact for personal use. +- Other repo-root entitlement files (`cmux.release.entitlements`, `cmux.nightly.entitlements`, `cmux-helper.entitlements`, `cmux.entitlements`) are consumed only by `scripts/sign-cmux-bundle.sh`, `scripts/build-sign-upload.sh`, and the release/nightly GitHub workflows. They do **not** affect local `xcodebuild` Release builds — leave them alone unless you plan to run those signing scripts. + +### Building on a fresh machine (e.g., the other laptop) + +```bash +git clone git@github.com:TraderSamwise/cmux.git +cd cmux +git checkout feature/quick-terminal-pr +./scripts/setup.sh # init submodules + build GhosttyKit.xcframework +./scripts/reloadp.sh # build Release and launch (works ad-hoc) +``` + +Install over `/Applications/cmux.app` once you're happy: + +```bash +pkill -x cmux; rm -rf /Applications/cmux.app \ + && ditto "$(find "$HOME/Library/Developer/Xcode/DerivedData" -path '*/Build/Products/Release/cmux.app' -print0 | xargs -0 stat -f '%m %N' | sort -nr | head -n1 | cut -d' ' -f2-)" /Applications/cmux.app \ + && touch /Applications/cmux.app \ + && open /Applications/cmux.app +``` + +(The `find | sort -nr | head -n1` picks the newest Release `cmux.app` from DerivedData — the directory hash is project-path-dependent and will differ across machines.) + +If `xcodebuild` fails with *"entitlements that require signing with a development certificate"*, the upstream `Resources/cmux.entitlements` has been re-added by an unintended merge. Check `git status`, delete it, and verify `CODE_SIGN_ENTITLEMENTS = "";` in the Release config of `GhosttyTabs.xcodeproj/project.pbxproj`. + ## Initial setup Run the setup script to initialize submodules and build GhosttyKit: diff --git a/README.md b/README.md index 2376987431f0..302942b55385 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@

cmux

A Ghostty-based macOS terminal with vertical tabs and notifications for AI coding agents

+> **Personal fork notice.** `TraderSamwise/cmux` is sam's personal fork; the long-lived branch is `feature/quick-terminal-pr`. It adds a quake-style quick-terminal panel and is built only with ad-hoc signing (no Apple Developer cert required). Build and merge notes specific to this fork live in [`CLAUDE.md`](./CLAUDE.md#personal-fork-notes-read-first). Original upstream: [manaflow-ai/cmux](https://github.com/manaflow-ai/cmux). +

Download cmux for macOS