diff --git a/README.md b/README.md
index 08c4582a..c0b203a5 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,10 @@
+
+ ▶ 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"])