diff --git a/README.md b/README.md index 08c4582a..c0b203a5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ cmux screenshot

+

+ ▶ Demo video +

+ ## Features diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2b2db96f..408123f4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -703,7 +703,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 460, height: 360), - styleMask: [.titled, .closable, .miniaturizable, .resizable], + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 726a90d7..4578fdcc 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -317,6 +317,16 @@ final class WindowBrowserPortal: NSObject { entry.containerView?.removeFromSuperview() } + /// Update the visibleInUI/zPriority state on an existing entry without rebinding. + /// Used when a bind is deferred (host not yet in window) so stale portal syncs + /// do not keep an old anchor visible. + func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.visibleInUI = visibleInUI + entry.zPriority = zPriority + entriesByWebViewId[webViewId] = entry + } + func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -853,6 +863,15 @@ enum BrowserWindowPortalRegistry { portal.synchronizeWebViewForAnchor(anchorView) } + /// Update visibleInUI/zPriority on an existing portal entry without rebinding. + /// Called when a bind is deferred because the new host is temporarily off-window. + static func updateEntryVisibility(for webView: WKWebView, visibleInUI: Bool, zPriority: Int) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) + } + static func detach(webView: WKWebView) { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 015ba8e6..5db2015a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1490,6 +1490,12 @@ struct VerticalTabsSidebar: View { .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .background(SidebarBackdrop().ignoresSafeArea()) + .background( + WindowAccessor { window in + commandKeyMonitor.setHostWindow(window) + } + .frame(width: 0, height: 0) + ) .onAppear { commandKeyMonitor.start() draggedTabId = nil @@ -1550,6 +1556,35 @@ enum SidebarCommandHintPolicy { static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool { modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] } + + static func isCurrentWindow( + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int? + ) -> Bool { + guard let hostWindowNumber, hostWindowIsKey else { return false } + if let eventWindowNumber { + return eventWindowNumber == hostWindowNumber + } + return keyWindowNumber == hostWindowNumber + } + + static func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int? + ) -> Bool { + shouldShowHints(for: modifierFlags) && + isCurrentWindow( + hostWindowNumber: hostWindowNumber, + hostWindowIsKey: hostWindowIsKey, + eventWindowNumber: eventWindowNumber, + keyWindowNumber: keyWindowNumber + ) + } } enum ShortcutHintDebugSettings { @@ -1794,28 +1829,63 @@ private struct SidebarExternalDropDelegate: DropDelegate { private final class SidebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? private var flagsMonitor: Any? private var keyDownMonitor: Any? - private var resignObserver: NSObjectProtocol? + private var appResignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? + func setHostWindow(_ window: NSWindow?) { + guard hostWindow !== window else { return } + removeHostWindowObservers() + hostWindow = window + guard let window else { + cancelPendingHintShow(resetVisible: true) + return + } + + hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.update(from: NSEvent.modifierFlags, eventWindow: nil) + } + } + + hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + func start() { guard flagsMonitor == nil else { - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) return } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - self?.update(from: event.modifierFlags) + self?.update(from: event.modifierFlags, eventWindow: event.window) return event } keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - self?.cancelPendingHintShow(resetVisible: true) + self?.handleKeyDown(event) return event } - resignObserver = NotificationCenter.default.addObserver( + appResignObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main @@ -1825,7 +1895,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } } - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) } func stop() { @@ -1837,15 +1907,36 @@ private final class SidebarCommandKeyMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } - if let resignObserver { - NotificationCenter.default.removeObserver(resignObserver) - self.resignObserver = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil } + removeHostWindowObservers() cancelPendingHintShow(resetVisible: true) } - private func update(from modifierFlags: NSEvent.ModifierFlags) { - guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else { + private func handleKeyDown(_ event: NSEvent) { + guard isCurrentWindow(eventWindow: event.window) else { return } + cancelPendingHintShow(resetVisible: true) + } + + private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) + } + + private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { + guard SidebarCommandHintPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { cancelPendingHintShow(resetVisible: true) return } @@ -1860,7 +1951,13 @@ private final class SidebarCommandKeyMonitor: ObservableObject { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return } + guard SidebarCommandHintPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } self.isCommandPressed = true } @@ -1875,6 +1972,17 @@ private final class SidebarCommandKeyMonitor: ObservableObject { isCommandPressed = false } } + + private func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil + } + } } #if DEBUG diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8122eee8..49438daf 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1352,7 +1352,7 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_PORT_RANGE"] = String(Self.sessionPortRangeSize) } - let claudeHooksEnabled = UserDefaults.standard.object(forKey: "claudeCodeHooksEnabled") as? Bool ?? true + let claudeHooksEnabled = ClaudeCodeIntegrationSettings.hooksEnabled() if !claudeHooksEnabled { env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1" } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f2b86d0c..1a7e9736 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1589,14 +1589,21 @@ extension BrowserPanel { ) #endif guard let inspector = webView.cmuxInspectorObject() else { return false } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + let isVisibleSelector = NSSelectorFromString("isVisible") + let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false let targetVisible = !visible let selector = NSSelectorFromString(targetVisible ? "show" : "close") guard inspector.responds(to: selector) else { return false } inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = targetVisible if targetVisible { - developerToolsRestoreRetryAttempt = 0 + let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + if visibleAfterToggle { + cancelDeveloperToolsRestoreRetry() + } else { + developerToolsRestoreRetryAttempt = 0 + scheduleDeveloperToolsRestoreRetry() + } } else { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 671b1de0..ef747d75 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -149,6 +149,7 @@ struct BrowserPanelView: View { @State private var lastHandledAddressBarFocusRequestId: UUID? private let omnibarPillCornerRadius: CGFloat = 12 private let addressBarButtonSize: CGFloat = 22 + private let addressBarButtonHitSize: CGFloat = 32 private let devToolsButtonIconSize: CGFloat = 11 private var searchEngine: BrowserSearchEngine { @@ -350,10 +351,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.left") .font(.system(size: 12, weight: .medium)) - .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) .help("Go Back") @@ -366,10 +367,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) - .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) .help("Go Forward") @@ -389,10 +390,10 @@ struct BrowserPanelView: View { }) { Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise") .font(.system(size: 12, weight: .medium)) - .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .help(panel.isLoading ? "Stop" : "Reload") } } @@ -2773,6 +2774,15 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.lastPortalHostId = hostId } BrowserWindowPortalRegistry.synchronizeForAnchor(host) + } else { + // Bind is deferred until host moves into a window. Keep the current + // portal entry's desired state in sync so stale callbacks cannot keep + // the previous anchor visible while this host is temporarily off-window. + BrowserWindowPortalRegistry.updateEntryVisibility( + for: webView, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) } panel.restoreDeveloperToolsAfterAttachIfNeeded() diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7c3f36a3..ece08a2b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2927,20 +2927,187 @@ class TerminalController { return result } + private enum V2PaneResizeDirection: String { + case left + case right + case up + case down + + var splitOrientation: String { + switch self { + case .left, .right: + return "horizontal" + case .up, .down: + return "vertical" + } + } + + /// A split controls the target pane's right/bottom edge when target is first child, + /// and left/top edge when target is second child. + var requiresPaneInFirstChild: Bool { + switch self { + case .right, .down: + return true + case .left, .up: + return false + } + } + + /// Positive value moves divider toward second child (right/down). + var dividerDeltaSign: CGFloat { + requiresPaneInFirstChild ? 1 : -1 + } + } + + private struct V2PaneResizeCandidate { + let splitId: UUID + let orientation: String + let paneInFirstChild: Bool + let dividerPosition: CGFloat + let axisPixels: CGFloat + } + + private struct V2PaneResizeTrace { + let containsTarget: Bool + let bounds: CGRect + } + + private func v2PaneResizeCollectCandidates( + node: ExternalTreeNode, + targetPaneId: String, + candidates: inout [V2PaneResizeCandidate] + ) -> V2PaneResizeTrace { + switch node { + case .pane(let pane): + let bounds = CGRect( + x: pane.frame.x, + y: pane.frame.y, + width: pane.frame.width, + height: pane.frame.height + ) + return V2PaneResizeTrace(containsTarget: pane.id == targetPaneId, bounds: bounds) + + case .split(let split): + let first = v2PaneResizeCollectCandidates( + node: split.first, + targetPaneId: targetPaneId, + candidates: &candidates + ) + let second = v2PaneResizeCollectCandidates( + node: split.second, + targetPaneId: targetPaneId, + candidates: &candidates + ) + + let combinedBounds = first.bounds.union(second.bounds) + let containsTarget = first.containsTarget || second.containsTarget + + if containsTarget, + let splitUUID = UUID(uuidString: split.id) { + let orientation = split.orientation.lowercased() + let axisPixels: CGFloat = orientation == "horizontal" + ? combinedBounds.width + : combinedBounds.height + candidates.append(V2PaneResizeCandidate( + splitId: splitUUID, + orientation: orientation, + paneInFirstChild: first.containsTarget, + dividerPosition: CGFloat(split.dividerPosition), + axisPixels: max(axisPixels, 1) + )) + } + + return V2PaneResizeTrace(containsTarget: containsTarget, bounds: combinedBounds) + } + } + private func v2PaneResize(params: [String: Any]) -> V2CallResult { - let direction = (v2String(params, "direction") ?? "").lowercased() + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let directionRaw = (v2String(params, "direction") ?? "").lowercased() let amount = v2Int(params, "amount") ?? 1 - guard ["left", "right", "up", "down"].contains(direction), amount > 0 else { + guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else { return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil) } - return .err( - code: "not_supported", - message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API", - data: [ - "direction": direction, - "amount": amount - ] - ) + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to resize pane", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id + guard let paneUUID else { + result = .err(code: "not_found", message: "No focused pane", data: nil) + return + } + guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else { + result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) + return + } + + let tree = ws.bonsplitController.treeSnapshot() + var candidates: [V2PaneResizeCandidate] = [] + let trace = v2PaneResizeCollectCandidates( + node: tree, + targetPaneId: paneUUID.uuidString, + candidates: &candidates + ) + guard trace.containsTarget else { + result = .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString]) + return + } + + let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation } + guard !orientationMatches.isEmpty else { + result = .err( + code: "invalid_state", + message: "No \(direction.splitOrientation) split ancestor for pane", + data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue] + ) + return + } + + guard let candidate = orientationMatches.first(where: { $0.paneInFirstChild == direction.requiresPaneInFirstChild }) else { + result = .err( + code: "invalid_state", + message: "Pane has no adjacent border in direction \(direction.rawValue)", + data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue] + ) + return + } + + let delta = CGFloat(amount) / candidate.axisPixels + let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta) + let clamped = min(max(requested, 0.1), 0.9) + guard ws.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else { + result = .err( + code: "internal_error", + message: "Failed to set split divider position", + data: ["split_id": candidate.splitId.uuidString] + ) + return + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": paneUUID.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), + "split_id": candidate.splitId.uuidString, + "direction": direction.rawValue, + "amount": amount, + "old_divider_position": candidate.dividerPosition, + "new_divider_position": clamped + ]) + } + return result } private func v2PaneSwap(params: [String: Any]) -> V2CallResult { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 5c96e1be..ff73c91a 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -276,6 +276,12 @@ struct TitlebarControlsView: View { controlsGroup(config: config) .padding(.leading, 4) .padding(.trailing, titlebarHintTrailingInset) + .background( + WindowAccessor { window in + commandKeyMonitor.setHostWindow(window) + } + .frame(width: 0, height: 0) + ) .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in shortcutRefreshTick &+= 1 } @@ -495,28 +501,63 @@ struct TitlebarControlsView: View { private final class TitlebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? private var flagsMonitor: Any? private var keyDownMonitor: Any? - private var resignObserver: NSObjectProtocol? + private var appResignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? + func setHostWindow(_ window: NSWindow?) { + guard hostWindow !== window else { return } + removeHostWindowObservers() + hostWindow = window + guard let window else { + cancelPendingHintShow(resetVisible: true) + return + } + + hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.update(from: NSEvent.modifierFlags, eventWindow: nil) + } + } + + hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + func start() { guard flagsMonitor == nil else { - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) return } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - self?.update(from: event.modifierFlags) + self?.update(from: event.modifierFlags, eventWindow: event.window) return event } keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - self?.cancelPendingHintShow(resetVisible: true) + self?.handleKeyDown(event) return event } - resignObserver = NotificationCenter.default.addObserver( + appResignObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main @@ -526,7 +567,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } } - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) } func stop() { @@ -538,15 +579,36 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } - if let resignObserver { - NotificationCenter.default.removeObserver(resignObserver) - self.resignObserver = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil } + removeHostWindowObservers() cancelPendingHintShow(resetVisible: true) } - private func update(from modifierFlags: NSEvent.ModifierFlags) { - guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else { + private func handleKeyDown(_ event: NSEvent) { + guard isCurrentWindow(eventWindow: event.window) else { return } + cancelPendingHintShow(resetVisible: true) + } + + private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) + } + + private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { + guard SidebarCommandHintPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { cancelPendingHintShow(resetVisible: true) return } @@ -561,7 +623,13 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return } + guard SidebarCommandHintPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } self.isCommandPressed = true } @@ -576,6 +644,17 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { isCommandPressed = false } } + + private func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil + } + } } final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ffcde89b..4eaabe4e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2398,13 +2398,26 @@ enum AppearanceSettings { } } +enum ClaudeCodeIntegrationSettings { + static let hooksEnabledKey = "claudeCodeHooksEnabled" + static let defaultHooksEnabled = false + + static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: hooksEnabledKey) == nil { + return defaultHooksEnabled + } + return defaults.bool(forKey: hooksEnabledKey) + } +} + struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue - @AppStorage("claudeCodeHooksEnabled") private var claudeCodeHooksEnabled = true + @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) + private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @AppStorage("cmuxPortBase") private var cmuxPortBase = 9100 @AppStorage("cmuxPortRange") private var cmuxPortRange = 10 @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @@ -2818,7 +2831,7 @@ struct SettingsView: View { private func resetAllSettings() { appearanceMode = AppearanceSettings.defaultMode.rawValue socketControlMode = SocketControlSettings.defaultMode.rawValue - claudeCodeHooksEnabled = true + claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index f4ddbf21..2de3d45c 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -500,6 +500,57 @@ final class SidebarCommandHintPolicyTests: XCTestCase { func testCommandHintUsesIntentionalHoldDelay() { XCTAssertGreaterThanOrEqual(SidebarCommandHintPolicy.intentionalHoldDelay, 0.25) } + + func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() { + XCTAssertTrue( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 42, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 7, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: false, + eventWindowNumber: 42, + keyWindowNumber: 42 + ) + ) + } + + func testWindowScopedCommandHintsUseKeyWindowWhenNoEventWindowIsAvailable() { + XCTAssertTrue( + SidebarCommandHintPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 7 + ) + ) + } } final class ShortcutHintDebugSettingsTests: XCTestCase { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index dddecf16..856407ba 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -162,6 +162,37 @@ final class GhosttyConfigTests: XCTestCase { ) } + func testClaudeCodeIntegrationDefaultsToDisabledWhenUnset() { + let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated user defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey) + XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) + } + + func testClaudeCodeIntegrationRespectsStoredPreference() { + let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated user defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set(true, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey) + XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) + + defaults.set(false, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey) + XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) + } + private func rgb255(_ color: NSColor) -> RGB { let srgb = color.usingColorSpace(.sRGB)! var red: CGFloat = 0 diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 2dc252fd..c782eee9 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -196,3 +196,45 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase { )) } } + +/// Regression test: ensure new terminal windows are born in full-size content mode so +/// titlebar/content offsets are correct before the first resize. +final class MainWindowLayoutStyleTests: XCTestCase { + func testCreateMainWindowUsesFullSizeContentViewStyleMask() throws { + let projectRoot = findProjectRoot() + let appDelegateURL = projectRoot.appendingPathComponent("Sources/AppDelegate.swift") + let source = try String(contentsOf: appDelegateURL, encoding: .utf8) + + guard let start = source.range(of: "func createMainWindow("), + let end = source.range(of: "@objc func checkForUpdates", range: start.upperBound.. URL { + var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() + for _ in 0..<10 { + let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") + if FileManager.default.fileExists(atPath: marker.path) { + return dir + } + dir = dir.deletingLastPathComponent() + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } +} diff --git a/tests/test_browser_devtools_portal_regressions.py b/tests/test_browser_devtools_portal_regressions.py new file mode 100644 index 00000000..6ec27096 --- /dev/null +++ b/tests/test_browser_devtools_portal_regressions.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Static regression checks for browser DevTools/portal review fixes. + +Guards two follow-up fixes: +1) DevTools toggle path must retry restore when inspector show is transiently ignored. +2) Browser portal visibility must propagate even if host is temporarily off-window. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + raise ValueError(f"Unbalanced braces for: {signature}") + + +def main() -> int: + root = repo_root() + failures: list[str] = [] + + panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" + panel_source = panel_path.read_text(encoding="utf-8") + toggle_block = extract_block(panel_source, "func toggleDeveloperTools() -> Bool") + if "visibleAfterToggle" not in toggle_block: + failures.append("toggleDeveloperTools() no longer re-checks inspector visibility") + if "scheduleDeveloperToolsRestoreRetry()" not in toggle_block: + failures.append("toggleDeveloperTools() no longer schedules a DevTools restore retry") + + view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" + view_source = view_path.read_text(encoding="utf-8") + portal_update_block = extract_block(view_source, "private func updateUsingWindowPortal(") + if "BrowserWindowPortalRegistry.updateEntryVisibility(" not in portal_update_block: + failures.append("BrowserPanelView.updateUsingWindowPortal() is missing deferred portal visibility propagation") + if "zPriority: coordinator.desiredPortalZPriority" not in portal_update_block: + failures.append("BrowserPanelView deferred portal update no longer propagates zPriority") + + portal_path = root / "Sources" / "BrowserWindowPortal.swift" + portal_source = portal_path.read_text(encoding="utf-8") + if not re.search( + r"func\s+updateEntryVisibility\s*\(\s*forWebViewId\s+webViewId:\s*ObjectIdentifier,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)", + portal_source, + flags=re.MULTILINE, + ): + failures.append("WindowBrowserPortal is missing updateEntryVisibility(forWebViewId:visibleInUI:zPriority:)") + if not re.search( + r"static\s+func\s+updateEntryVisibility\s*\(\s*for\s+webView:\s*WKWebView,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)", + portal_source, + flags=re.MULTILINE, + ): + failures.append("BrowserWindowPortalRegistry is missing updateEntryVisibility(for:visibleInUI:zPriority:)") + + if failures: + print("FAIL: browser devtools/portal regression guards failed") + for item in failures: + print(f" - {item}") + return 1 + + print("PASS: browser devtools/portal regression guards are in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_tmux_compat_matrix.py b/tests_v2/test_tmux_compat_matrix.py index 876e4130..59ee3d3a 100644 --- a/tests_v2/test_tmux_compat_matrix.py +++ b/tests_v2/test_tmux_compat_matrix.py @@ -82,6 +82,46 @@ def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> boo return token in str(payload.get("text") or "") +def _layout_panes(c: cmux) -> List[dict]: + layout_payload = c.layout_debug() or {} + layout = layout_payload.get("layout") or {} + panes = layout.get("panes") or [] + return list(panes) + + +def _pane_extent(c: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(c) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _pick_resize_target(c: cmux, pane_ids: List[str]) -> Tuple[str, str, str]: + panes = [p for p in _layout_panes(c) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + target = min(panes, key=x_of) + return str(target.get("paneId") or target.get("pane_id") or ""), "-R", "width" + + target = min(panes, key=y_of) + return str(target.get("paneId") or target.get("pane_id") or ""), "-D", "height" + + def main() -> int: cli = _find_cli_binary() stamp = int(time.time() * 1000) @@ -206,8 +246,13 @@ def main() -> int: merged = f"{proc.stdout}\n{proc.stderr}".lower() _must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}") - resize = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False) - _must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added") + resize_target, resize_flag, resize_axis = _pick_resize_target(c, current_panes) + pre_extent = _pane_extent(c, resize_target, resize_axis) + _run_cli(cli, ["resize-pane", "--pane", resize_target, resize_flag, "--amount", "80"]) + _wait_for( + lambda: _pane_extent(c, resize_target, resize_axis) > pre_extent + 1.0, + timeout_s=3.0, + ) buffer_token = f"TMUX_BUFFER_{stamp}" _run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"])