Merge remote-tracking branch 'origin/main' into task-white-rect-frame-cmd-d-ctrl-d

# Conflicts:
#	Sources/AppDelegate.swift
#	Sources/TerminalWindowPortal.swift
This commit is contained in:
Lawrence Chen 2026-02-23 16:54:42 -08:00
commit 638801cce8
17 changed files with 2194 additions and 440 deletions

View file

@ -7,6 +7,15 @@ on:
pull_request:
jobs:
workflow-guard-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate self-hosted runner guards
run: ./tests/test_ci_self_hosted_guard.sh
web-typecheck:
runs-on: ubuntu-latest
defaults:
@ -26,6 +35,8 @@ jobs:
run: bun tsc --noEmit
ui-tests:
# Never run self-hosted jobs for fork pull requests.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
concurrency:
group: self-hosted-build

View file

@ -36,6 +36,188 @@ enum FinderServicePathResolver {
}
}
enum TerminalDirectoryOpenTarget: String, CaseIterable {
case vscode
case cursor
case windsurf
case antigravity
case finder
case terminal
case iterm2
case ghostty
case warp
case xcode
case androidStudio
case zed
struct DetectionEnvironment {
let homeDirectoryPath: String
let fileExistsAtPath: (String) -> Bool
static let live = DetectionEnvironment(
homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path,
fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }
)
}
static var commandPaletteShortcutTargets: [Self] {
Array(allCases)
}
static func availableTargets(in environment: DetectionEnvironment = .live) -> Set<Self> {
Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) })
}
static let cachedLiveAvailableTargets: Set<Self> = availableTargets(in: .live)
var commandPaletteCommandId: String {
"palette.terminalOpenDirectory.\(rawValue)"
}
var commandPaletteTitle: String {
switch self {
case .vscode:
return "Open Current Directory in VS Code"
case .cursor:
return "Open Current Directory in Cursor"
case .windsurf:
return "Open Current Directory in Windsurf"
case .antigravity:
return "Open Current Directory in Antigravity"
case .finder:
return "Open Current Directory in Finder"
case .terminal:
return "Open Current Directory in Terminal"
case .iterm2:
return "Open Current Directory in iTerm2"
case .ghostty:
return "Open Current Directory in Ghostty"
case .warp:
return "Open Current Directory in Warp"
case .xcode:
return "Open Current Directory in Xcode"
case .androidStudio:
return "Open Current Directory in Android Studio"
case .zed:
return "Open Current Directory in Zed"
}
}
var commandPaletteKeywords: [String] {
let common = ["terminal", "directory", "open", "ide"]
switch self {
case .vscode:
return common + ["vs", "code", "visual", "studio"]
case .cursor:
return common + ["cursor"]
case .windsurf:
return common + ["windsurf"]
case .antigravity:
return common + ["antigravity"]
case .finder:
return common + ["finder", "file", "manager", "reveal"]
case .terminal:
return common + ["terminal", "shell"]
case .iterm2:
return common + ["iterm", "iterm2", "terminal", "shell"]
case .ghostty:
return common + ["ghostty", "terminal", "shell"]
case .warp:
return common + ["warp", "terminal", "shell"]
case .xcode:
return common + ["xcode", "apple"]
case .androidStudio:
return common + ["android", "studio"]
case .zed:
return common + ["zed"]
}
}
func isAvailable(in environment: DetectionEnvironment = .live) -> Bool {
applicationPath(in: environment) != nil
}
func applicationURL(in environment: DetectionEnvironment = .live) -> URL? {
guard let path = applicationPath(in: environment) else { return nil }
return URL(fileURLWithPath: path, isDirectory: true)
}
private func applicationPath(in environment: DetectionEnvironment) -> String? {
for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) {
return path
}
return nil
}
private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] {
let globalPrefix = "/Applications/"
let userPrefix = "\(environment.homeDirectoryPath)/Applications/"
var expanded: [String] = []
for candidate in applicationBundlePathCandidates {
expanded.append(candidate)
if candidate.hasPrefix(globalPrefix) {
let suffix = String(candidate.dropFirst(globalPrefix.count))
expanded.append(userPrefix + suffix)
}
}
return uniquePreservingOrder(expanded)
}
private var applicationBundlePathCandidates: [String] {
switch self {
case .vscode:
return [
"/Applications/Visual Studio Code.app",
"/Applications/Code.app",
]
case .cursor:
return [
"/Applications/Cursor.app",
"/Applications/Cursor Preview.app",
"/Applications/Cursor Nightly.app",
]
case .windsurf:
return ["/Applications/Windsurf.app"]
case .antigravity:
return ["/Applications/Antigravity.app"]
case .finder:
return ["/System/Library/CoreServices/Finder.app"]
case .terminal:
return ["/System/Applications/Utilities/Terminal.app"]
case .iterm2:
return [
"/Applications/iTerm.app",
"/Applications/iTerm2.app",
]
case .ghostty:
return ["/Applications/Ghostty.app"]
case .warp:
return ["/Applications/Warp.app"]
case .xcode:
return ["/Applications/Xcode.app"]
case .androidStudio:
return ["/Applications/Android Studio.app"]
case .zed:
return [
"/Applications/Zed.app",
"/Applications/Zed Preview.app",
"/Applications/Zed Nightly.app",
]
}
}
private func uniquePreservingOrder(_ paths: [String]) -> [String] {
var seen: Set<String> = []
var deduped: [String] = []
for path in paths where seen.insert(path).inserted {
deduped.append(path)
}
return deduped
}
}
enum WorkspaceShortcutMapper {
/// Maps Cmd+digit workspace shortcuts to a zero-based workspace index.
/// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace.
@ -70,10 +252,9 @@ func browserOmnibarSelectionDeltaForCommandNavigation(
chars: String
) -> Int? {
guard hasFocusedAddressBar else { return nil }
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
guard normalizedFlags == [.control] else { return nil }
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control]
guard isCommandOrControlOnly else { return nil }
if chars == "n" { return 1 }
if chars == "p" { return -1 }
return nil
@ -85,9 +266,7 @@ func browserOmnibarSelectionDeltaForArrowNavigation(
keyCode: UInt16
) -> Int? {
guard hasFocusedAddressBar else { return nil }
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
guard normalizedFlags == [] else { return nil }
switch keyCode {
case 125: return 1
@ -96,10 +275,14 @@ func browserOmnibarSelectionDeltaForArrowNavigation(
}
}
func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool {
let normalizedFlags = flags
func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags {
flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
.subtracting([.numericPad, .function, .capsLock])
}
func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool {
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
return normalizedFlags == [] || normalizedFlags == [.shift]
}
@ -204,6 +387,54 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil
}
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
guard let responder else { return nil }
if let ghosttyView = responder as? GhosttyNSView {
return ghosttyView
}
if let view = responder as? NSView,
let ghosttyView = cmuxOwningGhosttyView(for: view) {
return ghosttyView
}
if let textView = responder as? NSTextView,
let delegateView = textView.delegate as? NSView,
let ghosttyView = cmuxOwningGhosttyView(for: delegateView) {
return ghosttyView
}
var current = responder.nextResponder
while let next = current {
if let ghosttyView = next as? GhosttyNSView {
return ghosttyView
}
if let view = next as? NSView,
let ghosttyView = cmuxOwningGhosttyView(for: view) {
return ghosttyView
}
current = next.nextResponder
}
return nil
}
private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? {
if let ghosttyView = view as? GhosttyNSView {
return ghosttyView
}
var current: NSView? = view.superview
while let candidate = current {
if let ghosttyView = candidate as? GhosttyNSView {
return ghosttyView
}
current = candidate.superview
}
return nil
}
#if DEBUG
func browserZoomShortcutTraceCandidate(
flags: NSEvent.ModifierFlags,
@ -603,6 +834,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
sidebarSelectionState: SidebarSelectionState
) {
let key = ObjectIdentifier(window)
#if DEBUG
let priorManagerToken = debugManagerToken(self.tabManager)
#endif
if let existing = mainWindowContexts[key] {
existing.window = window
} else {
@ -626,6 +860,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
commandPaletteSelectionByWindowId[windowId] = 0
commandPaletteSnapshotByWindowId[windowId] = .empty
#if DEBUG
dlog(
"mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())"
)
#endif
if window.isKeyWindow {
setActiveMainWindow(window)
}
@ -855,6 +1094,117 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return mainWindowContexts[ObjectIdentifier(window)]
}
#if DEBUG
private func debugManagerToken(_ manager: TabManager?) -> String {
guard let manager else { return "nil" }
return String(describing: Unmanaged.passUnretained(manager).toOpaque())
}
private func debugWindowToken(_ window: NSWindow?) -> String {
guard let window else { return "nil" }
let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none"
let ident = window.identifier?.rawValue ?? "nil"
let shortIdent: String
if ident.count > 120 {
shortIdent = String(ident.prefix(120)) + "..."
} else {
shortIdent = ident
}
return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)"
}
private func debugContextToken(_ context: MainWindowContext?) -> String {
guard let context else { return "nil" }
let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0
return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)"
}
private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String {
let activeManager = tabManager
let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil"
let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
let contexts = mainWindowContexts.values
.map { context in
let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-"
let window = context.window ?? windowForMainWindowId(context.windowId)
let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}"
}
.sorted()
.joined(separator: ",")
let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil"
let eventWindow = event?.window
return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]"
}
#endif
private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? {
if let window = event.window, isMainTerminalWindow(window) {
return window
}
let eventWindowNumber = event.windowNumber
if eventWindowNumber > 0,
let numberedWindow = NSApp.window(withWindowNumber: eventWindowNumber),
isMainTerminalWindow(numberedWindow) {
return numberedWindow
}
if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) {
return keyWindow
}
if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) {
return mainWindow
}
return nil
}
/// Re-sync app-level active window pointers from the currently focused main terminal window.
/// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts.
@discardableResult
func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? {
let (context, source): (MainWindowContext?, String) = {
if let preferredWindow,
let context = contextForMainWindow(preferredWindow) {
return (context, "preferredWindow")
}
if let context = contextForMainWindow(NSApp.keyWindow) {
return (context, "keyWindow")
}
if let context = contextForMainWindow(NSApp.mainWindow) {
return (context, "mainWindow")
}
if let activeManager = tabManager,
let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) {
return (activeContext, "activeManager")
}
return (mainWindowContexts.values.first, "firstContextFallback")
}()
#if DEBUG
let beforeManagerToken = debugManagerToken(tabManager)
dlog(
"shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())"
)
#endif
guard let context else { return tabManager }
if let window = context.window ?? windowForMainWindowId(context.windowId) {
setActiveMainWindow(window)
} else {
tabManager = context.tabManager
sidebarState = context.sidebarState
sidebarSelectionState = context.sidebarSelectionState
TerminalController.shared.setActiveTabManager(context.tabManager)
}
#if DEBUG
dlog(
"shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())"
)
#endif
return context.tabManager
}
private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? {
if let context = contextForMainWindow(event.window) {
return context
@ -865,13 +1215,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if let context = contextForMainWindow(NSApp.mainWindow) {
return context
}
if let activeManager = tabManager,
let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) {
return activeContext
}
return mainWindowContexts.values.first
}
private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) {
guard let context = preferredMainWindowContextForShortcuts(event: event),
let window = context.window ?? windowForMainWindowId(context.windowId) else { return }
setActiveMainWindow(window)
let preferredWindow = mainWindowForShortcutEvent(event)
#if DEBUG
dlog(
"shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))"
)
#endif
_ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow)
#if DEBUG
dlog(
"shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))"
)
#endif
}
@discardableResult
@ -1849,7 +2212,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
}
let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")")
dlog(
"monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))"
)
if let probeKind = self.developerToolsShortcutProbeKind(event: event) {
self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event)
}
@ -2176,7 +2541,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// When the terminal has active IME composition (e.g. Korean, Japanese, Chinese
// input), don't intercept key events let them flow through to the input method.
if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView,
if let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder),
ghosttyView.hasMarkedText() {
return false
}
@ -2221,7 +2586,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// (e.g., split that doesn't properly blur the address bar). If the first responder
// is a terminal surface, the address bar can't be focused.
if browserAddressBarFocusedPanelId != nil,
NSApp.keyWindow?.firstResponder is GhosttyNSView {
cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil {
#if DEBUG
dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
#endif
@ -2258,6 +2623,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) {
#if DEBUG
dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))")
#endif
// Cmd+N semantics:
// - If there are no main windows, create a new window.
// - Otherwise, create a new workspace in the active window.
@ -2368,6 +2736,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let manager = tabManager,
let num = Int(chars),
let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) {
#if DEBUG
dlog(
"shortcut.action name=workspaceDigit digit=\(num) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))"
)
#endif
manager.selectTab(at: targetIndex)
return true
}
@ -2436,6 +2809,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// Split actions: Cmd+D / Cmd+Shift+D
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) {
#if DEBUG
dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))")
#endif
if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) {
return true
}
@ -2444,6 +2820,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) {
#if DEBUG
dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))")
#endif
if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) {
return true
}
@ -2678,10 +3057,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
chars: String
) -> Bool {
guard browserAddressBarFocusedPanelId != nil else { return false }
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
guard normalizedFlags == [.control] else { return false }
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control]
guard isCommandOrControlOnly else { return false }
return chars == "n" || chars == "p"
}
@ -2867,6 +3245,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@discardableResult
func performSplitShortcut(direction: SplitDirection) -> Bool {
_ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow)
let directionLabel: String
switch direction {
case .left: directionLabel = "left"
@ -2932,6 +3312,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@discardableResult
func performBrowserSplitShortcut(direction: SplitDirection) -> Bool {
_ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow)
guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false }
_ = focusBrowserAddressBar(panelId: panelId)
return true
@ -3296,10 +3678,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func setActiveMainWindow(_ window: NSWindow) {
guard isMainTerminalWindow(window) else { return }
guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return }
#if DEBUG
let beforeManagerToken = debugManagerToken(tabManager)
#endif
tabManager = context.tabManager
sidebarState = context.sidebarState
sidebarSelectionState = context.sidebarSelectionState
TerminalController.shared.setActiveTabManager(context.tabManager)
#if DEBUG
dlog(
"mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())"
)
#endif
}
private func unregisterMainWindow(_ window: NSWindow) {
@ -3342,6 +3732,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func isMainTerminalWindow(_ window: NSWindow) -> Bool {
if mainWindowContexts[ObjectIdentifier(window)] != nil {
return true
}
guard let raw = window.identifier?.rawValue else { return false }
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
}
@ -4338,7 +4731,8 @@ private extension NSWindow {
// Command shortcuts when the terminal is focused the local event monitor
// (handleCustomShortcut) already handles app-level shortcuts, and anything
// remaining should be menu items.
if let ghosttyView = self.firstResponder as? GhosttyNSView {
let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder)
if let ghosttyView = firstResponderGhosttyView {
// If the IME is composing, don't intercept key events let them flow
// through normal AppKit event dispatch so the input method can process them.
if ghosttyView.hasMarkedText() {
@ -4381,7 +4775,7 @@ private extension NSWindow {
// When the terminal is focused, skip the full NSWindow.performKeyEquivalent
// (which walks the SwiftUI content view hierarchy) and dispatch Command-key
// events directly to the main menu. This avoids the broken SwiftUI focus path.
if self.firstResponder is GhosttyNSView,
if firstResponderGhosttyView != nil,
event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
let mainMenu = NSApp.mainMenu {
let consumedByMenu = mainMenu.performKeyEquivalent(with: event)

View file

@ -326,6 +326,8 @@ final class WindowBrowserPortal: NSObject {
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
private struct Entry {
weak var webView: WKWebView?
@ -345,9 +347,73 @@ final class WindowBrowserPortal: NSObject {
hostView.layer?.masksToBounds = true
hostView.translatesAutoresizingMaskIntoConstraints = true
hostView.autoresizingMask = []
installGeometryObservers(for: window)
_ = ensureInstalled()
}
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
let center = NotificationCenter.default
geometryObservers.append(center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSSplitView.didResizeSubviewsNotification,
object: nil,
queue: .main
) { [weak self] notification in
MainActor.assumeIsolated {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
self.scheduleExternalGeometrySynchronize()
}
})
}
private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}
private func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasExternalGeometrySyncScheduled = false
self.synchronizeAllEntriesFromExternalGeometryChange()
}
}
private func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
installedContainerView?.layoutSubtreeIfNeeded()
installedReferenceView?.layoutSubtreeIfNeeded()
hostView.superview?.layoutSubtreeIfNeeded()
hostView.layoutSubtreeIfNeeded()
synchronizeAllWebViews(excluding: nil, source: "externalGeometry")
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window else { return false }
@ -419,13 +485,32 @@ final class WindowBrowserPortal: NSObject {
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
frame.minX < bounds.minX - epsilon ||
frame.minY < bounds.minY - epsilon ||
@ -765,7 +850,8 @@ final class WindowBrowserPortal: NSObject {
_ = synchronizeHostFrameToReference()
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
@ -838,6 +924,8 @@ final class WindowBrowserPortal: NSObject {
CATransaction.setDisableActions(true)
containerView.frame = targetFrame
CATransaction.commit()
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
}
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
@ -952,6 +1040,7 @@ final class WindowBrowserPortal: NSObject {
}
func tearDown() {
removeGeometryObservers()
for webViewId in Array(entriesByWebViewId.keys) {
detachWebView(withId: webViewId)
}

View file

@ -977,14 +977,6 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind
return controller
}
private struct CommandPaletteRowFramePreferenceKey: PreferenceKey {
static var defaultValue: [Int: CGRect] = [:]
static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, rhs in rhs })
}
}
enum WorkspaceMountPolicy {
// Keep only the selected workspace mounted to minimize layer-tree traversal.
static let maxMountedWorkspaces = 1
@ -1120,8 +1112,8 @@ struct ContentView: View {
@State private var commandPaletteRenameDraft: String = ""
@State private var commandPaletteSelectedResultIndex: Int = 0
@State private var commandPaletteHoveredResultIndex: Int?
@State private var commandPaletteLastSelectionIndex: Int = 0
@State private var commandPaletteRowFrames: [Int: CGRect] = [:]
@State private var commandPaletteScrollTargetIndex: Int?
@State private var commandPaletteScrollTargetAnchor: UnitPoint?
@State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget?
@State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:]
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
@ -1197,11 +1189,6 @@ struct ContentView: View {
case kind
}
enum CommandPaletteScrollAnchor: Equatable {
case top
case bottom
}
private struct CommandPaletteTrailingLabel {
let text: String
let style: CommandPaletteTrailingLabelStyle
@ -1277,6 +1264,10 @@ struct ContentView: View {
static let panelHasUnread = "panel.hasUnread"
static let updateHasAvailable = "update.hasAvailable"
static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String {
"terminal.openTarget.\(target.rawValue).available"
}
}
private struct CommandPaletteCommandContribution {
@ -1343,6 +1334,8 @@ struct ContentView: View {
)
private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1"
private static let commandPaletteCommandsPrefix = ">"
private static let minimumSidebarWidth: CGFloat = 186
private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0
private enum SidebarResizerHandle: Hashable {
case divider
@ -1352,8 +1345,31 @@ struct ContentView: View {
SidebarResizeInteraction.hitWidthPerSide
}
private var maxSidebarWidth: CGFloat {
(NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3
private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat {
let resolvedAvailableWidth = availableWidth
?? observedWindow?.contentView?.bounds.width
?? observedWindow?.contentLayoutRect.width
?? NSApp.keyWindow?.contentView?.bounds.width
?? NSApp.keyWindow?.contentLayoutRect.width
if let resolvedAvailableWidth, resolvedAvailableWidth > 0 {
return max(Self.minimumSidebarWidth, resolvedAvailableWidth * Self.maximumSidebarWidthRatio)
}
let fallbackScreenWidth = NSApp.keyWindow?.screen?.frame.width
?? NSScreen.main?.frame.width
?? 1920
return max(Self.minimumSidebarWidth, fallbackScreenWidth * Self.maximumSidebarWidthRatio)
}
private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) {
let nextWidth = max(
Self.minimumSidebarWidth,
min(maxSidebarWidth(availableWidth: availableWidth), sidebarWidth)
)
guard abs(nextWidth - sidebarWidth) > 0.5 else { return }
withTransaction(Transaction(animation: nil)) {
sidebarWidth = nextWidth
}
}
private func activateSidebarResizerCursor() {
@ -1498,6 +1514,7 @@ struct ContentView: View {
private func sidebarResizerHandleOverlay(
_ handle: SidebarResizerHandle,
width: CGFloat,
availableWidth: CGFloat,
accessibilityIdentifier: String? = nil
) -> some View {
Color.clear
@ -1543,7 +1560,10 @@ struct ContentView: View {
activateSidebarResizerCursor()
let startWidth = sidebarDragStartWidth ?? sidebarWidth
let nextWidth = max(186, min(maxSidebarWidth, startWidth + value.translation.width))
let nextWidth = max(
Self.minimumSidebarWidth,
min(maxSidebarWidth(availableWidth: availableWidth), startWidth + value.translation.width)
)
withTransaction(Transaction(animation: nil)) {
sidebarWidth = nextWidth
}
@ -1574,6 +1594,7 @@ struct ContentView: View {
sidebarResizerHandleOverlay(
.divider,
width: sidebarResizerHitWidthPerSide * 2,
availableWidth: totalWidth,
accessibilityIdentifier: "SidebarResizer"
)
@ -1582,6 +1603,12 @@ struct ContentView: View {
.allowsHitTesting(false)
}
.frame(width: totalWidth, height: proxy.size.height, alignment: .leading)
.onAppear {
clampSidebarWidthIfNeeded(availableWidth: totalWidth)
}
.onChange(of: totalWidth) {
clampSidebarWidthIfNeeded(availableWidth: totalWidth)
}
}
}
@ -1610,7 +1637,11 @@ struct ContentView: View {
ForEach(mountedWorkspaces) { tab in
let isSelectedWorkspace = selectedWorkspaceId == tab.id
let isRetiringWorkspace = retiringWorkspaceId == tab.id
let isInputActive = isSelectedWorkspace || isRetiringWorkspace
// Keep the retiring workspace visible during handoff, but never input-active.
// Allowing both selected+retiring workspaces to be input-active lets the
// old workspace steal first responder (notably with WKWebView), which can
// delay handoff completion and make browser returns feel laggy.
let isInputActive = isSelectedWorkspace
let isVisible = isSelectedWorkspace || isRetiringWorkspace
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
WorkspaceContentView(
@ -1952,6 +1983,25 @@ struct ContentView: View {
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder")
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in
guard let webView = notification.object as? WKWebView,
let selectedTabId = tabManager.selectedTabId,
let selectedWorkspace = tabManager.selectedWorkspace,
let focusedPanelId = selectedWorkspace.focusedPanelId,
let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId),
focusedBrowser.webView === webView else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder")
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in
guard let panelId = notification.object as? UUID,
let selectedTabId = tabManager.selectedTabId,
let selectedWorkspace = tabManager.selectedWorkspace,
selectedWorkspace.focusedPanelId == panelId,
selectedWorkspace.browserPanel(for: panelId) != nil else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar")
})
view = AnyView(view.onReceive(tabManager.$tabs) { tabs in
let existingIds = Set(tabs.map { $0.id })
if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) {
@ -2102,6 +2152,13 @@ struct ContentView: View {
AppDelegate.shared?.fullscreenControlsViewModel = nil
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didResizeNotification)) { notification in
guard let window = notification.object as? NSWindow,
window === observedWindow else { return }
clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width)
updateSidebarResizerBandState()
})
view = AnyView(view.onChange(of: sidebarWidth) { _ in
updateSidebarResizerBandState()
})
@ -2129,6 +2186,7 @@ struct ContentView: View {
DispatchQueue.main.async {
observedWindow = window
isFullScreen = window.styleMask.contains(.fullScreen)
clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width)
syncCommandPaletteDebugStateForObservedWindow()
installSidebarResizerPointerMonitorIfNeeded()
updateSidebarResizerBandState()
@ -2377,7 +2435,7 @@ struct ContentView: View {
private var commandPaletteCommandListView: some View {
let visibleResults = Array(commandPaletteResults)
let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
let commandPaletteListMaxHeight: CGFloat = 216
let commandPaletteListMaxHeight: CGFloat = 450
let commandPaletteRowHeight: CGFloat = 24
let commandPaletteEmptyStateHeight: CGFloat = 44
let commandPaletteListContentHeight = visibleResults.isEmpty
@ -2421,133 +2479,85 @@ struct ContentView: View {
Divider()
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
if visibleResults.isEmpty {
Text(commandPaletteEmptyStateText)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 12)
} else {
ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in
let isSelected = index == selectedIndex
let isHovered = commandPaletteHoveredResultIndex == index
let rowBackground: Color = isSelected
? Color.accentColor.opacity(0.12)
: (isHovered ? Color.primary.opacity(0.08) : .clear)
ScrollView {
LazyVStack(spacing: 0) {
if visibleResults.isEmpty {
Text(commandPaletteEmptyStateText)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 12)
} else {
ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in
let isSelected = index == selectedIndex
let isHovered = commandPaletteHoveredResultIndex == index
let rowBackground: Color = isSelected
? Color.accentColor.opacity(0.12)
: (isHovered ? Color.primary.opacity(0.08) : .clear)
Button {
runCommandPaletteCommand(result.command)
} label: {
HStack(spacing: 8) {
commandPaletteHighlightedTitleText(
result.command.title,
matchedIndices: result.titleMatchIndices
)
.font(.system(size: 13, weight: .regular))
.lineLimit(1)
Spacer()
if let trailingLabel = commandPaletteTrailingLabel(for: result.command) {
switch trailingLabel.style {
case .shortcut:
Text(trailingLabel.text)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous))
case .kind:
Text(trailingLabel.text)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.padding(.horizontal, 9)
.padding(.vertical, 2)
.frame(maxWidth: .infinity, alignment: .leading)
.background(rowBackground)
.background(
GeometryReader { geometry in
Color.clear.preference(
key: CommandPaletteRowFramePreferenceKey.self,
value: [index: geometry.frame(in: .named("commandPaletteListScroll"))]
)
}
Button {
runCommandPaletteCommand(result.command)
} label: {
HStack(spacing: 8) {
commandPaletteHighlightedTitleText(
result.command.title,
matchedIndices: result.titleMatchIndices
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.id(index)
.onHover { hovering in
if hovering {
commandPaletteHoveredResultIndex = index
} else if commandPaletteHoveredResultIndex == index {
commandPaletteHoveredResultIndex = nil
.font(.system(size: 13, weight: .regular))
.lineLimit(1)
Spacer()
if let trailingLabel = commandPaletteTrailingLabel(for: result.command) {
switch trailingLabel.style {
case .shortcut:
Text(trailingLabel.text)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous))
case .kind:
Text(trailingLabel.text)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.padding(.horizontal, 9)
.padding(.vertical, 2)
.frame(maxWidth: .infinity, alignment: .leading)
.background(rowBackground)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.id(index)
.onHover { hovering in
if hovering {
commandPaletteHoveredResultIndex = index
} else if commandPaletteHoveredResultIndex == index {
commandPaletteHoveredResultIndex = nil
}
}
}
}
// Force a fresh row tree per query so rendered labels/actions stay in lockstep.
.id(commandPaletteQuery)
}
.coordinateSpace(name: "commandPaletteListScroll")
.frame(height: commandPaletteListHeight)
.onChange(of: commandPaletteSelectedResultIndex) { _ in
guard !visibleResults.isEmpty else { return }
let index = commandPaletteSelectedIndex(resultCount: visibleResults.count)
let previousIndex = commandPaletteLastSelectionIndex
defer { commandPaletteLastSelectionIndex = index }
guard let anchorDecision = Self.commandPaletteScrollAnchor(
selectedIndex: index,
previousIndex: previousIndex,
resultCount: visibleResults.count,
selectedFrame: commandPaletteRowFrames[index],
viewportHeight: commandPaletteListHeight,
contentHeight: commandPaletteListContentHeight
) else { return }
let anchor: UnitPoint
switch anchorDecision {
case .top:
anchor = .top
case .bottom:
anchor = .bottom
}
DispatchQueue.main.async {
withAnimation(.easeOut(duration: 0.1)) {
proxy.scrollTo(index, anchor: anchor)
}
}
}
.onChange(of: visibleResults.count) { _ in
commandPaletteLastSelectionIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
}
.onPreferenceChange(CommandPaletteRowFramePreferenceKey.self) { frames in
commandPaletteRowFrames = frames
guard !visibleResults.isEmpty else { return }
let index = commandPaletteSelectedIndex(resultCount: visibleResults.count)
guard let anchorDecision = Self.commandPaletteEdgeVisibilityCorrectionAnchor(
selectedIndex: index,
resultCount: visibleResults.count,
selectedFrame: frames[index],
viewportHeight: commandPaletteListHeight,
contentHeight: commandPaletteListContentHeight
) else { return }
let anchor: UnitPoint = anchorDecision == .top ? .top : .bottom
DispatchQueue.main.async {
withAnimation(.easeOut(duration: 0.08)) {
proxy.scrollTo(index, anchor: anchor)
}
}
}
.scrollTargetLayout()
// Force a fresh row tree per query so rendered labels/actions stay in lockstep.
.id(commandPaletteQuery)
}
.frame(height: commandPaletteListHeight)
.scrollPosition(
id: Binding(
get: { commandPaletteScrollTargetIndex },
// Ignore passive readback so manual scrolling doesn't mutate selection-follow state.
set: { _ in }
),
anchor: commandPaletteScrollTargetAnchor
)
.onChange(of: commandPaletteSelectedResultIndex) { _ in
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true)
}
// Keep Esc-to-close behavior without showing footer controls.
@ -2562,20 +2572,19 @@ struct ContentView: View {
}
.onAppear {
commandPaletteHoveredResultIndex = nil
commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex
commandPaletteRowFrames = [:]
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false)
resetCommandPaletteSearchFocus()
}
.onChange(of: commandPaletteQuery) { _ in
commandPaletteSelectedResultIndex = 0
commandPaletteHoveredResultIndex = nil
commandPaletteLastSelectionIndex = 0
commandPaletteRowFrames = [:]
commandPaletteScrollTargetIndex = nil
commandPaletteScrollTargetAnchor = nil
syncCommandPaletteDebugStateForObservedWindow()
}
.onChange(of: visibleResults.count) { _ in
commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false)
if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count {
commandPaletteHoveredResultIndex = nil
}
@ -3178,18 +3187,29 @@ struct ContentView: View {
if let panelContext = focusedPanelContext {
let workspace = panelContext.workspace
let panelId = panelContext.panelId
let panelIsTerminal = panelContext.panel.panelType == .terminal
snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true)
snapshot.setString(
CommandPaletteContextKeys.panelName,
panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle)
)
snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser)
snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelContext.panel.panelType == .terminal)
snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal)
snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil)
snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId))
let hasUnread = workspace.manualUnreadPanelIds.contains(panelId)
|| notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)
snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread)
if panelIsTerminal {
let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
snapshot.setBool(
CommandPaletteContextKeys.terminalOpenTargetAvailable(target),
availableTargets.contains(target)
)
}
}
}
if case .updateAvailable = updateViewModel.effectiveState {
@ -3600,15 +3620,20 @@ struct ContentView: View {
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.terminalOpenDirectory",
title: constant("Open Current Directory in IDE"),
subtitle: terminalPanelSubtitle,
keywords: ["terminal", "directory", "open", "ide", "code", "default app"],
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
contributions.append(
CommandPaletteCommandContribution(
commandId: target.commandPaletteCommandId,
title: constant(target.commandPaletteTitle),
subtitle: terminalPanelSubtitle,
keywords: target.commandPaletteKeywords,
when: { context in
context.bool(CommandPaletteContextKeys.panelIsTerminal)
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target))
}
)
)
)
}
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.terminalFind",
@ -3871,9 +3896,11 @@ struct ContentView: View {
_ = tabManager.createBrowserSplit(direction: .right, url: url)
}
registry.register(commandId: "palette.terminalOpenDirectory") {
if !openFocusedDirectoryInDefaultApp() {
NSSound.beep()
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
registry.register(commandId: target.commandPaletteCommandId) {
if !openFocusedDirectory(in: target) {
NSSound.beep()
}
}
}
registry.register(commandId: "palette.terminalFind") {
@ -3937,61 +3964,43 @@ struct ContentView: View {
return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1)
}
static func commandPaletteScrollAnchor(
static func commandPaletteScrollPositionAnchor(
selectedIndex: Int,
previousIndex: Int,
resultCount: Int,
selectedFrame: CGRect?,
viewportHeight: CGFloat,
contentHeight: CGFloat,
epsilon: CGFloat = 0.5
) -> CommandPaletteScrollAnchor? {
resultCount: Int
) -> UnitPoint? {
guard resultCount > 0 else { return nil }
guard contentHeight > viewportHeight else { return nil }
// Always pin edges exactly into view when selection reaches first/last.
if selectedIndex <= 0 {
return .top
return UnitPoint.top
}
if selectedIndex >= resultCount - 1 {
return .bottom
return UnitPoint.bottom
}
if let frame = selectedFrame,
frame.minY >= (0 - epsilon),
frame.maxY <= (viewportHeight + epsilon) {
return nil
}
return selectedIndex >= previousIndex ? .bottom : .top
return nil
}
static func commandPaletteEdgeVisibilityCorrectionAnchor(
selectedIndex: Int,
resultCount: Int,
selectedFrame: CGRect?,
viewportHeight: CGFloat,
contentHeight: CGFloat,
epsilon: CGFloat = 0.5
) -> CommandPaletteScrollAnchor? {
guard resultCount > 0 else { return nil }
guard contentHeight > viewportHeight else { return nil }
let isTop = selectedIndex <= 0
let isBottom = selectedIndex >= (resultCount - 1)
guard isTop || isBottom else { return nil }
guard let frame = selectedFrame else {
return isTop ? .top : .bottom
private func updateCommandPaletteScrollTarget(resultCount: Int, animated: Bool) {
guard resultCount > 0 else {
commandPaletteScrollTargetIndex = nil
commandPaletteScrollTargetAnchor = nil
return
}
if isTop {
let topDelta = abs(frame.minY)
return topDelta > epsilon ? .top : nil
}
let selectedIndex = commandPaletteSelectedIndex(resultCount: resultCount)
commandPaletteScrollTargetAnchor = Self.commandPaletteScrollPositionAnchor(
selectedIndex: selectedIndex,
resultCount: resultCount
)
let bottomDelta = abs(frame.maxY - viewportHeight)
return bottomDelta > epsilon ? .bottom : nil
let assignTarget = {
commandPaletteScrollTargetIndex = selectedIndex
}
if animated {
withAnimation(.easeOut(duration: 0.1)) {
assignTarget()
}
} else {
assignTarget()
}
}
private func moveCommandPaletteSelection(by delta: Int) {
@ -4185,8 +4194,8 @@ struct ContentView: View {
commandPaletteRenameDraft = ""
commandPaletteSelectedResultIndex = 0
commandPaletteHoveredResultIndex = nil
commandPaletteLastSelectionIndex = 0
commandPaletteRowFrames = [:]
commandPaletteScrollTargetIndex = nil
commandPaletteScrollTargetAnchor = nil
resetCommandPaletteSearchFocus()
syncCommandPaletteDebugStateForObservedWindow()
}
@ -4199,8 +4208,8 @@ struct ContentView: View {
commandPaletteRenameDraft = ""
commandPaletteSelectedResultIndex = 0
commandPaletteHoveredResultIndex = nil
commandPaletteLastSelectionIndex = 0
commandPaletteRowFrames = [:]
commandPaletteScrollTargetIndex = nil
commandPaletteScrollTargetAnchor = nil
isCommandPaletteSearchFocused = false
isCommandPaletteRenameFocused = false
commandPaletteRestoreFocusTarget = nil
@ -4427,9 +4436,22 @@ struct ContentView: View {
return NSWorkspace.shared.open(url)
}
private func openFocusedDirectoryInDefaultApp() -> Bool {
private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool {
guard let directoryURL = focusedTerminalDirectoryURL() else { return false }
return NSWorkspace.shared.open(directoryURL)
return openFocusedDirectory(directoryURL, in: target)
}
private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool {
switch target {
case .finder:
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path)
return true
default:
guard let applicationURL = target.applicationURL() else { return false }
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration)
return true
}
}
private func focusedTerminalDirectoryURL() -> URL? {

View file

@ -1501,6 +1501,17 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
#endif
/// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived
/// from backing-space points and truncated (never rounded up).
private func pixelDimension(from value: CGFloat) -> UInt32 {
guard value.isFinite else { return 0 }
let floored = floor(max(0, value))
if floored >= CGFloat(UInt32.max) {
return UInt32.max
}
return UInt32(floored)
}
private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) {
let scale = max(
1.0,
@ -1616,6 +1627,13 @@ final class TerminalSurface: Identifiable, ObservableObject {
surfaceCallbackContext = callbackContext
surfaceConfig.scale_factor = scaleFactors.layer
surfaceConfig.context = surfaceContext
#if DEBUG
let templateFontText = String(format: "%.2f", surfaceConfig.font_size)
dlog(
"zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " +
"templateFont=\(templateFontText)"
)
#endif
var envVars: [ghostty_env_var_s] = []
var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = []
defer {
@ -1761,6 +1779,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
#endif
return
}
guard let createdSurface = surface else { return }
// For vsync-driven rendering, Ghostty needs to know which display we're on so it can
// start a CVDisplayLink with the right refresh rate. If we don't set this early, the
@ -1772,29 +1791,66 @@ final class TerminalSurface: Identifiable, ObservableObject {
if let screen = view.window?.screen ?? NSScreen.main,
let displayID = screen.displayID,
displayID != 0 {
ghostty_surface_set_display_id(surface, displayID)
ghostty_surface_set_display_id(createdSurface, displayID)
}
ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y)
let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero))
let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero))
ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y)
let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size
let wpx = pixelDimension(from: backingSize.width)
let hpx = pixelDimension(from: backingSize.height)
if wpx > 0, hpx > 0 {
ghostty_surface_set_size(surface, wpx, hpx)
ghostty_surface_set_size(createdSurface, wpx, hpx)
lastPixelWidth = wpx
lastPixelHeight = hpx
lastXScale = scaleFactors.x
lastYScale = scaleFactors.y
}
// Some GhosttyKit builds can drop inherited font_size during post-create
// config/scale reconciliation. If runtime points don't match the inherited
// template points, re-apply via binding action so all creation paths
// (new surface, split, new workspace) preserve zoom from the source terminal.
if let inheritedFontPoints = configTemplate?.font_size,
inheritedFontPoints > 0 {
let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface)
let shouldReapply = {
guard let currentFontPoints else { return true }
return abs(currentFontPoints - inheritedFontPoints) > 0.05
}()
if shouldReapply {
let action = String(format: "set_font_size:%.3f", inheritedFontPoints)
_ = performBindingAction(action)
}
}
flushPendingTextIfNeeded()
#if DEBUG
let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map {
String(format: "%.2f", $0)
} ?? "nil"
dlog(
"zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " +
"runtimeFont=\(runtimeFontText)"
)
#endif
}
func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) {
func updateSize(
width: CGFloat,
height: CGFloat,
xScale: CGFloat,
yScale: CGFloat,
layerScale: CGFloat,
backingSize: CGSize? = nil
) {
guard let surface = surface else { return }
_ = layerScale
let wpx = UInt32((width * xScale).rounded(.toNearestOrAwayFromZero))
let hpx = UInt32((height * yScale).rounded(.toNearestOrAwayFromZero))
let resolvedBackingWidth = backingSize?.width ?? (width * xScale)
let resolvedBackingHeight = backingSize?.height ?? (height * yScale)
let wpx = pixelDimension(from: resolvedBackingWidth)
let hpx = pixelDimension(from: resolvedBackingHeight)
guard wpx > 0, hpx > 0 else { return }
let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale)
@ -2079,6 +2135,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private func setup() {
// Only enable our instrumented CAMetalLayer in targeted debug/test scenarios.
// The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs.
wantsLayer = true
layer?.masksToBounds = true
installEventMonitor()
updateTrackingAreas()
registerForDraggedTypes(Array(Self.dropTypes))
@ -2206,17 +2264,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
ghostty_surface_set_display_id(surface, displayID)
}
// Recompute from current bounds after layout, not stale pending sizes.
// Recompute from current bounds after layout. Pending size is only a fallback
// when we don't have usable bounds (e.g. detached/off-window transitions).
superview?.layoutSubtreeIfNeeded()
layoutSubtreeIfNeeded()
let targetSize: CGSize = {
let current = bounds.size
if current.width > 0, current.height > 0 {
return current
}
return pendingSurfaceSize ?? current
}()
updateSurfaceSize(size: targetSize)
updateSurfaceSize()
applySurfaceBackground()
applySurfaceColorScheme(force: true)
applyWindowBackgroundIfActive()
@ -2256,9 +2308,30 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
override var isOpaque: Bool { false }
private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize {
if let size,
size.width > 0,
size.height > 0 {
return size
}
let currentBounds = bounds.size
if currentBounds.width > 0, currentBounds.height > 0 {
return currentBounds
}
if let pending = pendingSurfaceSize,
pending.width > 0,
pending.height > 0 {
return pending
}
return currentBounds
}
private func updateSurfaceSize(size: CGSize? = nil) {
guard let terminalSurface = terminalSurface else { return }
let size = size ?? bounds.size
let size = resolvedSurfaceSize(preferred: size)
guard size.width > 0 && size.height > 0 else {
#if DEBUG
let signature = "nonPositive-\(Int(size.width))x\(Int(size.height))"
@ -2318,12 +2391,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let xScale = backingSize.width / size.width
let yScale = backingSize.height / size.height
let layerScale = max(1.0, window.backingScaleFactor)
let drawablePixelSize = CGSize(
width: floor(max(0, backingSize.width)),
height: floor(max(0, backingSize.height))
)
CATransaction.begin()
CATransaction.setDisableActions(true)
layer?.contentsScale = layerScale
layer?.masksToBounds = true
if let metalLayer = layer as? CAMetalLayer {
metalLayer.drawableSize = backingSize
metalLayer.drawableSize = drawablePixelSize
}
CATransaction.commit()
@ -2332,9 +2410,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
height: size.height,
xScale: xScale,
yScale: yScale,
layerScale: layerScale
layerScale: layerScale,
backingSize: backingSize
)
pendingSurfaceSize = nil
}
fileprivate func pushTargetSurfaceSize(_ size: CGSize) {
@ -3524,6 +3602,8 @@ final class GhosttySurfaceScrollView: NSView {
documentView.addSubview(surfaceView)
super.init(frame: .zero)
wantsLayer = true
layer?.masksToBounds = true
backgroundView.wantsLayer = true
backgroundView.layer?.backgroundColor =
@ -3661,6 +3741,12 @@ final class GhosttySurfaceScrollView: NSView {
synchronizeGeometryAndContent()
}
/// Request an immediate terminal redraw after geometry updates so stale IOSurface
/// contents do not remain stretched during live resize churn.
func refreshSurfaceNow() {
surfaceView.terminalSurface?.forceRefresh()
}
private func synchronizeGeometryAndContent() {
CATransaction.begin()
CATransaction.setDisableActions(true)
@ -3670,7 +3756,6 @@ final class GhosttySurfaceScrollView: NSView {
scrollView.frame = bounds
let targetSize = scrollView.bounds.size
surfaceView.frame.size = targetSize
surfaceView.pushTargetSurfaceSize(targetSize)
documentView.frame.size.width = scrollView.bounds.width
inactiveOverlayView.frame = bounds
if let zone = activeDropZone {
@ -3694,6 +3779,7 @@ final class GhosttySurfaceScrollView: NSView {
updateFlashPath()
synchronizeScrollView()
synchronizeSurfaceView()
synchronizeCoreSurface()
}
override func viewDidMoveToWindow() {
@ -3713,8 +3799,13 @@ final class GhosttySurfaceScrollView: NSView {
object: window,
queue: .main
) { [weak self] _ in
// No-op: focus is driven by first-responder changes.
_ = self
guard let self, let window = self.window else { return }
// Losing key window does not always trigger first-responder resignation, so force
// the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync.
if let fr = window.firstResponder as? NSView,
fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) {
window.makeFirstResponder(nil)
}
})
if window.isKeyWindow { applyFirstResponderIfNeeded() }
}
@ -4566,6 +4657,15 @@ final class GhosttySurfaceScrollView: NSView {
surfaceView.frame.origin = visibleRect.origin
}
/// Match upstream Ghostty behavior: use content area width (excluding non-content
/// regions such as scrollbar space) when telling libghostty the terminal size.
private func synchronizeCoreSurface() {
let width = scrollView.contentSize.width
let height = surfaceView.frame.height
guard width > 0, height > 0 else { return }
surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
}
private func updateNotificationRingPath() {
updateOverlayRingPath(
layer: notificationRingLayer,

View file

@ -380,6 +380,21 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest {
return preparedRequest
}
private let browserEmbeddedNavigationSchemes: Set<String> = [
"about",
"applewebdata",
"blob",
"data",
"http",
"https",
"javascript",
]
func browserShouldOpenURLExternally(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false }
return !browserEmbeddedNavigationSchemes.contains(scheme)
}
enum BrowserUserAgentSettings {
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
@ -2638,6 +2653,22 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
return
}
// WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.).
// Hand these off to macOS so the owning app can handle them.
if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false,
browserShouldOpenURLExternally(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
decisionHandler(.cancel)
return
}
// target=_blank or window.open() navigate in the current webview
if navigationAction.targetFrame == nil,
navigationAction.request.url != nil {
@ -2761,6 +2792,16 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url {
if browserShouldOpenURLExternally(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
return nil
}
if let requestNavigation {
let intent: BrowserInsecureHTTPNavigationIntent =
navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab

View file

@ -3070,6 +3070,7 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator: Coordinator,
generation: Int
) {
let retryInterval: TimeInterval = 1.0 / 60.0
// Don't schedule multiple overlapping retries.
guard coordinator.attachRetryWorkItem == nil else { return }
@ -3102,7 +3103,7 @@ struct WebViewRepresentable: NSViewRepresentable {
// Be generous here: bonsplit structural updates can keep a representable
// container off-window longer than a few seconds under load.
if coordinator.attachRetryCount < 400 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
scheduleAttachRetry(
webView,
panel: panel,
@ -3139,7 +3140,7 @@ struct WebViewRepresentable: NSViewRepresentable {
}
coordinator.attachRetryWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work)
}
func updateNSView(_ nsView: NSView, context: Context) {

View file

@ -45,6 +45,9 @@ final class CmuxWebView: WKWebView {
return false
}
let result = super.becomeFirstResponder()
if result {
NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self)
}
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(

View file

@ -753,9 +753,15 @@ class TabManager: ObservableObject {
@discardableResult
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace {
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal)
let newWorkspace = Workspace(
title: "Terminal \(tabs.count + 1)",
workingDirectory: workingDirectory,
portOrdinal: ordinal,
configTemplate: inheritedConfig
)
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex()
if insertIndex >= 0 && insertIndex <= tabs.count {
@ -785,6 +791,36 @@ class TabManager: ObservableObject {
@discardableResult
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
if let focusedTerminal = workspace.focusedTerminalPanel {
return focusedTerminal
}
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() {
return rememberedTerminal
}
if let focusedPaneId = workspace.bonsplitController.focusedPaneId,
let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) {
return paneTerminal
}
return workspace.terminalPanelForConfigInheritance()
}
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB
)
}
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
return config
}
return nil
}
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
guard let directory else { return nil }
let normalized = normalizeDirectory(directory)
@ -3113,6 +3149,7 @@ extension Notification.Name {
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView")
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")

View file

@ -546,6 +546,8 @@ final class WindowTerminalPortal: NSObject {
private weak var installedReferenceView: NSView?
private var installConstraints: [NSLayoutConstraint] = []
private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
#if DEBUG
private var lastLoggedBonsplitContainerSignature: String?
#endif
@ -563,13 +565,141 @@ final class WindowTerminalPortal: NSObject {
init(window: NSWindow) {
self.window = window
super.init()
hostView.wantsLayer = false
hostView.wantsLayer = true
hostView.layer?.masksToBounds = true
hostView.postsFrameChangedNotifications = true
hostView.postsBoundsChangedNotifications = true
hostView.translatesAutoresizingMaskIntoConstraints = false
dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true
dividerOverlayView.autoresizingMask = [.width, .height]
installGeometryObservers(for: window)
_ = ensureInstalled()
}
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
let center = NotificationCenter.default
geometryObservers.append(center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSSplitView.didResizeSubviewsNotification,
object: nil,
queue: .main
) { [weak self] notification in
MainActor.assumeIsolated {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
self.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSView.frameDidChangeNotification,
object: hostView,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSView.boundsDidChangeNotification,
object: hostView,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
}
private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}
private func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasExternalGeometrySyncScheduled = false
self.synchronizeAllEntriesFromExternalGeometryChange()
}
}
private func synchronizeLayoutHierarchy() {
installedContainerView?.layoutSubtreeIfNeeded()
installedReferenceView?.layoutSubtreeIfNeeded()
hostView.superview?.layoutSubtreeIfNeeded()
hostView.layoutSubtreeIfNeeded()
_ = synchronizeHostFrameToReference()
}
@discardableResult
private func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostView.frame = frameInContainer
CATransaction.commit()
#if DEBUG
dlog(
"portal.hostFrame.update host=\(portalDebugToken(hostView)) " +
"frame=\(portalDebugFrame(frameInContainer))"
)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
}
private func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
synchronizeAllHostedViews(excluding: nil)
// During live resize, AppKit can deliver frame churn where host/container geometry
// settles a tick before the terminal's own scroll/surface hierarchy. Force a final
// in-place geometry + surface refresh for all visible entries in this window.
for entry in entriesByHostedId.values {
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
}
}
private func ensureDividerOverlayOnTop() {
if dividerOverlayView.superview !== hostView {
dividerOverlayView.frame = hostView.bounds
@ -618,6 +748,8 @@ final class WindowTerminalPortal: NSObject {
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
}
synchronizeLayoutHierarchy()
_ = synchronizeHostFrameToReference()
ensureDividerOverlayOnTop()
return true
@ -647,13 +779,32 @@ final class WindowTerminalPortal: NSObject {
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else {
@ -691,6 +842,58 @@ final class WindowTerminalPortal: NSObject {
}
#endif
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
/// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when
/// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective
/// visible rect that should drive portal geometry.
private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
var current = anchorView.superview
while let ancestor = current {
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
let finiteAncestorBounds =
ancestorBoundsInWindow.origin.x.isFinite &&
ancestorBoundsInWindow.origin.y.isFinite &&
ancestorBoundsInWindow.size.width.isFinite &&
ancestorBoundsInWindow.size.height.isFinite
if finiteAncestorBounds {
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
if frameInWindow.isNull { return .zero }
}
if ancestor === installedReferenceView { break }
current = ancestor.superview
}
return frameInWindow
}
private func seededFrameInHost(for anchorView: NSView) -> NSRect? {
_ = synchronizeHostFrameToReference()
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
guard hasFiniteFrame else { return nil }
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
if hasFiniteHostBounds {
let clampedFrame = frameInHost.intersection(hostBounds)
if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 {
return clampedFrame
}
}
return frameInHost
}
func detachHostedView(withId hostedId: ObjectIdentifier) {
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
if let anchor = entry.anchorView {
@ -782,6 +985,32 @@ final class WindowTerminalPortal: NSObject {
}
#endif
_ = synchronizeHostFrameToReference()
// Seed frame/bounds before entering the window so a freshly reparented
// surface doesn't do a transient 800x600 size update on viewDidMoveToWindow.
if let seededFrame = seededFrameInHost(for: anchorView),
seededFrame.width > 0,
seededFrame.height > 0 {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = seededFrame
hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size)
CATransaction.commit()
} else {
// If anchor geometry is still unsettled, keep this hidden/zero-sized until
// synchronizeHostedView resolves a valid target frame on the next layout tick.
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = .zero
hostedView.bounds = .zero
CATransaction.commit()
hostedView.isHidden = true
}
// Keep inner scroll/surface geometry in sync with the seeded outer frame
// before the hosted view enters a window.
hostedView.reconcileGeometryNow()
if hostedView.superview !== hostView {
#if DEBUG
dlog(
@ -807,10 +1036,13 @@ final class WindowTerminalPortal: NSObject {
ensureDividerOverlayOnTop()
synchronizeHostedView(withId: hostedId)
scheduleDeferredFullSynchronizeAll()
pruneDeadEntries()
}
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
pruneDeadEntries()
let anchorId = ObjectIdentifier(anchorView)
let primaryHostedId = hostedByAnchorId[anchorId]
@ -837,6 +1069,7 @@ final class WindowTerminalPortal: NSObject {
private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
pruneDeadEntries()
let hostedIds = Array(entriesByHostedId.keys)
for hostedId in hostedIds {
@ -879,24 +1112,51 @@ final class WindowTerminalPortal: NSObject {
return
}
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil)
_ = synchronizeHostFrameToReference()
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
#if DEBUG
logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView)
#endif
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
if !hostBoundsReady {
#if DEBUG
dlog(
"portal.sync.defer hosted=\(portalDebugToken(hostedView)) " +
"reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " +
"anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
)
#endif
hostedView.isHidden = true
scheduleDeferredFullSynchronizeAll()
return
}
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
let clampedFrame = frameInHost.intersection(hostBounds)
let hasVisibleIntersection =
!clampedFrame.isNull &&
clampedFrame.width > 1 &&
clampedFrame.height > 1
let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
let tinyFrame =
frameInHost.width <= Self.tinyHideThreshold ||
frameInHost.height <= Self.tinyHideThreshold
targetFrame.width <= Self.tinyHideThreshold ||
targetFrame.height <= Self.tinyHideThreshold
let revealReadyForDisplay =
frameInHost.width >= Self.minimumRevealWidth &&
frameInHost.height >= Self.minimumRevealHeight
let outsideHostBounds = !frameInHost.intersects(hostView.bounds)
targetFrame.width >= Self.minimumRevealWidth &&
targetFrame.height >= Self.minimumRevealHeight
let outsideHostBounds = !hasVisibleIntersection
let shouldHide =
!entry.visibleInUI ||
anchorHidden ||
@ -907,17 +1167,26 @@ final class WindowTerminalPortal: NSObject {
let oldFrame = hostedView.frame
#if DEBUG
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
if frameWasClamped {
dlog(
"portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) " +
"raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
}
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
if collapsedToTiny {
dlog(
"portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
)
} else if restoredFromTiny {
dlog(
"portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
)
}
#endif
@ -931,23 +1200,29 @@ final class WindowTerminalPortal: NSObject {
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))"
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
#endif
hostedView.isHidden = true
}
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = frameInHost
hostedView.frame = targetFrame
CATransaction.commit()
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
}
if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 ||
abs(oldFrame.size.height - frameInHost.size.height) > 0.5,
!shouldHide,
(!hostedView.isHidden || revealReadyForDisplay) {
hostedView.reconcileGeometryNow()
if hasFiniteFrame {
let expectedBounds = NSRect(origin: .zero, size: targetFrame.size)
if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.bounds = expectedBounds
CATransaction.commit()
}
}
@ -968,12 +1243,25 @@ final class WindowTerminalPortal: NSObject {
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))"
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
#endif
hostedView.isHidden = false
}
#if DEBUG
dlog(
"portal.sync.result hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " +
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
"old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " +
"target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " +
"hostBounds=\(portalDebugFrame(hostBounds))"
)
#endif
ensureDividerOverlayOnTop()
}
@ -1007,6 +1295,7 @@ final class WindowTerminalPortal: NSObject {
}
func tearDown() {
removeGeometryObservers()
for hostedId in Array(entriesByHostedId.keys) {
detachHostedView(withId: hostedId)
}

View file

@ -3,6 +3,58 @@ import SwiftUI
import AppKit
import Bonsplit
import Combine
import CoreText
func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String {
switch context {
case GHOSTTY_SURFACE_CONTEXT_WINDOW:
return "window"
case GHOSTTY_SURFACE_CONTEXT_TAB:
return "tab"
case GHOSTTY_SURFACE_CONTEXT_SPLIT:
return "split"
default:
return "unknown(\(context))"
}
}
func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? {
guard let quicklookFont = ghostty_surface_quicklook_font(surface) else {
return nil
}
let ctFont = Unmanaged<CTFont>.fromOpaque(quicklookFont).takeRetainedValue()
let points = Float(CTFontGetSize(ctFont))
guard points > 0 else { return nil }
return points
}
func cmuxInheritedSurfaceConfig(
sourceSurface: ghostty_surface_t,
context: ghostty_surface_context_e
) -> ghostty_surface_config_s {
let inherited = ghostty_surface_inherited_config(sourceSurface, context)
var config = inherited
// Make runtime zoom inheritance explicit, even when Ghostty's
// inherit-font-size config is disabled.
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
if let points = runtimePoints {
config.font_size = points
}
#if DEBUG
let inheritedText = String(format: "%.2f", inherited.font_size)
let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil"
let finalText = String(format: "%.2f", config.font_size)
dlog(
"zoom.inherit context=\(cmuxSurfaceContextName(context)) " +
"inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)"
)
#endif
return config
}
struct SidebarStatusEntry {
let key: String
@ -261,6 +313,15 @@ final class Workspace: Identifiable, ObservableObject {
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
private var isProgrammaticSplit = false
/// Last terminal panel used as an inheritance source (typically last focused terminal).
private var lastTerminalConfigInheritancePanelId: UUID?
/// Last known terminal font points from inheritance sources. Used as fallback when
/// no live terminal surface is currently available.
private var lastTerminalConfigInheritanceFontPoints: Float?
/// Per-panel inherited zoom lineage. Descendants reuse this root value unless
/// a panel is explicitly re-zoomed by the user.
private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:]
/// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore.
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
@ -376,7 +437,12 @@ final class Workspace: Identifiable, ObservableObject {
}
}
init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) {
init(
title: String = "Terminal",
workingDirectory: String? = nil,
portOrdinal: Int = 0,
configTemplate: ghostty_surface_config_s? = nil
) {
self.id = UUID()
self.portOrdinal = portOrdinal
self.processTitle = title
@ -414,11 +480,13 @@ final class Workspace: Identifiable, ObservableObject {
let terminalPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: configTemplate,
workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil,
portOrdinal: portOrdinal
)
panels[terminalPanel.id] = terminalPanel
panelTitles[terminalPanel.id] = terminalPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate)
// Create initial tab in bonsplit and store the mapping
var initialTabId: TabID?
@ -919,6 +987,169 @@ final class Workspace: Identifiable, ObservableObject {
// MARK: - Panel Operations
private func seedTerminalInheritanceFontPoints(
panelId: UUID,
configTemplate: ghostty_surface_config_s?
) {
guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return }
terminalInheritanceFontPointsByPanelId[panelId] = fontPoints
lastTerminalConfigInheritanceFontPoints = fontPoints
}
private func resolvedTerminalInheritanceFontPoints(
for terminalPanel: TerminalPanel,
sourceSurface: ghostty_surface_t,
inheritedConfig: ghostty_surface_config_s
) -> Float? {
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 {
if let runtimePoints, abs(runtimePoints - rooted) > 0.05 {
// Runtime zoom changed after lineage was seeded (manual zoom on descendant);
// treat runtime as the new root for future descendants.
return runtimePoints
}
return rooted
}
if inheritedConfig.font_size > 0 {
return inheritedConfig.font_size
}
return runtimePoints
}
private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) {
lastTerminalConfigInheritancePanelId = terminalPanel.id
if let sourceSurface = terminalPanel.surface.surface,
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) {
let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 {
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints
}
lastTerminalConfigInheritanceFontPoints =
terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints
}
}
func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? {
guard let panelId = lastTerminalConfigInheritancePanelId else { return nil }
return terminalPanel(for: panelId)
}
func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? {
lastTerminalConfigInheritanceFontPoints
}
/// Candidate terminal panels used as the source when creating inherited Ghostty config.
/// Preference order:
/// 1) explicitly preferred terminal panel (when the caller has one),
/// 2) selected terminal in the target pane,
/// 3) currently focused terminal in the workspace,
/// 4) last remembered terminal source,
/// 5) first terminal tab in the target pane,
/// 6) deterministic workspace fallback.
private func terminalPanelConfigInheritanceCandidates(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> [TerminalPanel] {
var candidates: [TerminalPanel] = []
var seen: Set<UUID> = []
func appendCandidate(_ panel: TerminalPanel?) {
guard let panel, seen.insert(panel.id).inserted else { return }
candidates.append(panel)
}
if let preferredPanelId,
let terminalPanel = terminalPanel(for: preferredPanelId) {
appendCandidate(terminalPanel)
}
if let preferredPaneId,
let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id,
let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId),
let selectedTerminalPanel = terminalPanel(for: selectedPanelId) {
appendCandidate(selectedTerminalPanel)
}
if let focusedTerminalPanel {
appendCandidate(focusedTerminalPanel)
}
if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() {
appendCandidate(rememberedTerminalPanel)
}
if let preferredPaneId {
for tab in bonsplitController.tabs(inPane: preferredPaneId) {
guard let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId) else { continue }
appendCandidate(terminalPanel)
}
}
for terminalPanel in panels.values
.compactMap({ $0 as? TerminalPanel })
.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {
appendCandidate(terminalPanel)
}
return candidates
}
/// Picks the first terminal panel candidate used as the inheritance source.
func terminalPanelForConfigInheritance(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> TerminalPanel? {
terminalPanelConfigInheritanceCandidates(
preferredPanelId: preferredPanelId,
inPane: preferredPaneId
).first
}
private func inheritedTerminalConfig(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> ghostty_surface_config_s? {
// Walk candidates in priority order and use the first panel with a live surface.
// This avoids returning nil when the top candidate exists but is not attached yet.
for terminalPanel in terminalPanelConfigInheritanceCandidates(
preferredPanelId: preferredPanelId,
inPane: preferredPaneId
) {
guard let sourceSurface = terminalPanel.surface.surface else { continue }
var config = cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT
)
if let rootedFontPoints = resolvedTerminalInheritanceFontPoints(
for: terminalPanel,
sourceSurface: sourceSurface,
inheritedConfig: config
), rootedFontPoints > 0 {
config.font_size = rootedFontPoints
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints
}
rememberTerminalConfigInheritanceSource(terminalPanel)
if config.font_size > 0 {
lastTerminalConfigInheritanceFontPoints = config.font_size
}
return config
}
if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
#if DEBUG
dlog(
"zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))"
)
#endif
return config
}
return nil
}
/// Create a new split with a terminal panel
@discardableResult
func newTerminalSplit(
@ -927,22 +1158,6 @@ final class Workspace: Identifiable, ObservableObject {
insertFirst: Bool = false,
focus: Bool = true
) -> TerminalPanel? {
// Get inherited config from the source terminal when possible.
// If the split is initiated from a non-terminal panel (for example browser),
// fall back to any terminal in the workspace.
let inheritedConfig: ghostty_surface_config_s? = {
if let sourceTerminal = terminalPanel(for: panelId),
let existing = sourceTerminal.surface.surface {
return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
if let fallbackSurface = panels.values
.compactMap({ ($0 as? TerminalPanel)?.surface.surface })
.first {
return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
return nil
}()
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
@ -955,6 +1170,7 @@ final class Workspace: Identifiable, ObservableObject {
}
guard let paneId = sourcePaneId else { return nil }
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
// Create the new terminal panel.
let newPanel = TerminalPanel(
@ -965,6 +1181,7 @@ final class Workspace: Identifiable, ObservableObject {
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit
// mutates layout state (avoids transient "Empty Panel" flashes during split).
@ -989,6 +1206,7 @@ final class Workspace: Identifiable, ObservableObject {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
surfaceIdToPanelId.removeValue(forKey: newTab.id)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
@ -1024,16 +1242,7 @@ final class Workspace: Identifiable, ObservableObject {
func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
// Get an existing terminal panel to inherit config from
let inheritedConfig: ghostty_surface_config_s? = {
for panel in panels.values {
if let terminalPanel = panel as? TerminalPanel,
let surface = terminalPanel.surface.surface {
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
}
return nil
}()
let inheritedConfig = inheritedTerminalConfig(inPane: paneId)
// Create new terminal panel
let newPanel = TerminalPanel(
@ -1044,6 +1253,7 @@ final class Workspace: Identifiable, ObservableObject {
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Create tab in bonsplit
guard let newTabId = bonsplitController.createTab(
@ -1056,6 +1266,7 @@ final class Workspace: Identifiable, ObservableObject {
) else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
@ -1819,14 +2030,19 @@ final class Workspace: Identifiable, ObservableObject {
/// Create a new terminal panel (used when replacing the last panel)
@discardableResult
func createReplacementTerminalPanel() -> TerminalPanel {
let inheritedConfig = inheritedTerminalConfig(
preferredPanelId: focusedPanelId,
inPane: bonsplitController.focusedPaneId
)
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: nil,
configTemplate: inheritedConfig,
portOrdinal: portOrdinal
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Create tab in bonsplit
if let newTabId = bonsplitController.createTab(
@ -2100,6 +2316,9 @@ extension Workspace: BonsplitDelegate {
}
panel.focus()
if let terminalPanel = panel as? TerminalPanel {
rememberTerminalConfigInheritanceSource(terminalPanel)
}
let isManuallyUnread = manualUnreadPanelIds.contains(panelId)
let markedAt = manualUnreadMarkedAt[panelId]
if Self.shouldClearManualUnread(
@ -2327,6 +2546,10 @@ extension Workspace: BonsplitDelegate {
panelSubscriptions.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId)
if lastTerminalConfigInheritancePanelId == panelId {
lastTerminalConfigInheritancePanelId = nil
}
// Keep the workspace invariant: always retain at least one real panel.
// This prevents runtime close callbacks from ever collapsing into a tabless workspace.
@ -2519,15 +2742,7 @@ extension Workspace: BonsplitDelegate {
// Keep the existing placeholder tab identity and replace only the panel mapping.
// This avoids an extra create+close tab churn that can transiently render an
// empty pane during drag-to-split of a single-tab pane.
let inheritedConfig: ghostty_surface_config_s? = {
for panel in panels.values {
if let terminalPanel = panel as? TerminalPanel,
let surface = terminalPanel.surface.surface {
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
}
return nil
}()
let inheritedConfig = inheritedTerminalConfig(inPane: originalPane)
let replacementPanel = TerminalPanel(
workspaceId: id,
@ -2537,6 +2752,7 @@ extension Workspace: BonsplitDelegate {
)
panels[replacementPanel.id] = replacementPanel
panelTitles[replacementPanel.id] = replacementPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig)
surfaceIdToPanelId[replacementTab.id] = replacementPanel.id
bonsplitController.updateTab(
@ -2579,7 +2795,7 @@ extension Workspace: BonsplitDelegate {
// Get the focused terminal in the original pane to inherit config from
guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id,
let sourcePanelId = panelIdFromSurfaceId(sourceTabId),
let sourcePanel = terminalPanel(for: sourcePanelId) else { return }
terminalPanel(for: sourcePanelId) != nil else { return }
#if DEBUG
dlog(
@ -2588,11 +2804,10 @@ extension Workspace: BonsplitDelegate {
)
#endif
let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface {
ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
} else {
nil
}
let inheritedConfig = inheritedTerminalConfig(
preferredPanelId: sourcePanelId,
inPane: originalPane
)
let newPanel = TerminalPanel(
workspaceId: id,
@ -2602,6 +2817,7 @@ extension Workspace: BonsplitDelegate {
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
guard let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
@ -2613,6 +2829,7 @@ extension Workspace: BonsplitDelegate {
) else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return
}

View file

@ -357,7 +357,7 @@ struct cmuxApp: App {
}
splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).addTab()
activeTabManager.addTab()
}
}
@ -392,7 +392,7 @@ struct cmuxApp: App {
}
Button("Reopen Closed Browser Panel") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel()
_ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
}
.keyboardShortcut("t", modifiers: [.command, .shift])
}
@ -401,35 +401,35 @@ struct cmuxApp: App {
CommandGroup(after: .textEditing) {
Menu("Find") {
Button("Find…") {
(AppDelegate.shared?.tabManager ?? tabManager).startSearch()
activeTabManager.startSearch()
}
.keyboardShortcut("f", modifiers: .command)
Button("Find Next") {
(AppDelegate.shared?.tabManager ?? tabManager).findNext()
activeTabManager.findNext()
}
.keyboardShortcut("g", modifiers: .command)
Button("Find Previous") {
(AppDelegate.shared?.tabManager ?? tabManager).findPrevious()
activeTabManager.findPrevious()
}
.keyboardShortcut("g", modifiers: [.command, .shift])
Divider()
Button("Hide Find Bar") {
(AppDelegate.shared?.tabManager ?? tabManager).hideFind()
activeTabManager.hideFind()
}
.keyboardShortcut("f", modifiers: [.command, .shift])
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible))
.disabled(!(activeTabManager.isFindVisible))
Divider()
Button("Use Selection for Find") {
(AppDelegate.shared?.tabManager ?? tabManager).searchSelection()
activeTabManager.searchSelection()
}
.keyboardShortcut("e", modifiers: .command)
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind))
.disabled(!(activeTabManager.canUseSelectionForFind))
}
}
@ -444,54 +444,54 @@ struct cmuxApp: App {
Divider()
splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface()
activeTabManager.selectNextSurface()
}
splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface()
activeTabManager.selectPreviousSurface()
}
Button("Back") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack()
activeTabManager.focusedBrowserPanel?.goBack()
}
.keyboardShortcut("[", modifiers: .command)
Button("Forward") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward()
activeTabManager.focusedBrowserPanel?.goForward()
}
.keyboardShortcut("]", modifiers: .command)
Button("Reload Page") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload()
activeTabManager.focusedBrowserPanel?.reload()
}
.keyboardShortcut("r", modifiers: .command)
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
let manager = activeTabManager
if !manager.toggleDeveloperToolsFocusedBrowser() {
NSSound.beep()
}
}
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
let manager = activeTabManager
if !manager.showJavaScriptConsoleFocusedBrowser() {
NSSound.beep()
}
}
Button("Zoom In") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser()
_ = activeTabManager.zoomInFocusedBrowser()
}
.keyboardShortcut("=", modifiers: .command)
Button("Zoom Out") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser()
_ = activeTabManager.zoomOutFocusedBrowser()
}
.keyboardShortcut("-", modifiers: .command)
Button("Actual Size") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser()
_ = activeTabManager.resetZoomFocusedBrowser()
}
.keyboardShortcut("0", modifiers: .command)
@ -500,11 +500,11 @@ struct cmuxApp: App {
}
splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
activeTabManager.selectNextTab()
}
splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab()
activeTabManager.selectPreviousTab()
}
splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) {
@ -534,7 +534,7 @@ struct cmuxApp: App {
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
ForEach(1...9, id: \.self) { number in
Button("Workspace \(number)") {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
let manager = activeTabManager
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
manager.selectTab(at: targetIndex)
}
@ -705,6 +705,12 @@ struct cmuxApp: App {
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
}
private var activeTabManager: TabManager {
AppDelegate.shared?.synchronizeActiveMainWindowContext(
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
) ?? tabManager
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
@ -756,11 +762,11 @@ struct cmuxApp: App {
window.performClose(nil)
return
}
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation()
activeTabManager.closeCurrentPanelWithConfirmation()
}
private func closeTabOrWindow() {
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation()
activeTabManager.closeCurrentTabWithConfirmation()
}
private func showNotificationsPopover() {

View file

@ -376,6 +376,127 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
}
@MainActor
final class AppDelegateWindowContextRoutingTests: XCTestCase {
private func makeMainWindow(id: UUID) -> NSWindow {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)")
return window
}
func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() {
_ = NSApplication.shared
let app = AppDelegate()
let windowAId = UUID()
let windowBId = UUID()
let windowA = makeMainWindow(id: windowAId)
let windowB = makeMainWindow(id: windowBId)
defer {
windowA.orderOut(nil)
windowB.orderOut(nil)
}
let managerA = TabManager()
let managerB = TabManager()
app.registerMainWindow(
windowA,
windowId: windowAId,
tabManager: managerA,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
app.registerMainWindow(
windowB,
windowId: windowBId,
tabManager: managerB,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
windowB.makeKeyAndOrderFront(nil)
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB)
XCTAssertTrue(app.tabManager === managerB)
windowA.makeKeyAndOrderFront(nil)
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager")
XCTAssertTrue(app.tabManager === managerA)
}
func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() {
_ = NSApplication.shared
let app = AppDelegate()
let windowAId = UUID()
let windowBId = UUID()
let windowA = makeMainWindow(id: windowAId)
let windowB = makeMainWindow(id: windowBId)
defer {
windowA.orderOut(nil)
windowB.orderOut(nil)
}
let managerA = TabManager()
let managerB = TabManager()
app.registerMainWindow(
windowA,
windowId: windowAId,
tabManager: managerA,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
app.registerMainWindow(
windowB,
windowId: windowBId,
tabManager: managerB,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
// Seed active manager and clear focus windows to force fallback routing.
windowA.makeKeyAndOrderFront(nil)
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
XCTAssertTrue(app.tabManager === managerA)
windowA.orderOut(nil)
windowB.orderOut(nil)
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil)
XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window")
XCTAssertTrue(app.tabManager === managerA)
}
func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() {
_ = NSApplication.shared
let app = AppDelegate()
let windowId = UUID()
let window = makeMainWindow(id: windowId)
defer { window.orderOut(nil) }
let manager = TabManager()
app.registerMainWindow(
window,
windowId: windowId,
tabManager: manager,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
// SwiftUI can replace the NSWindow identifier string at runtime.
window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged")
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window)
XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed")
XCTAssertTrue(app.tabManager === manager)
}
}
final class FocusFlashPatternTests: XCTestCase {
func testFocusFlashPatternMatchesTerminalDoublePulseShape() {
XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0])
@ -1048,6 +1169,25 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase {
)
}
func testArrowNavigationDeltaIgnoresCapsLockModifier() {
XCTAssertEqual(
browserOmnibarSelectionDeltaForArrowNavigation(
hasFocusedAddressBar: true,
flags: [.capsLock],
keyCode: 126
),
-1
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForArrowNavigation(
hasFocusedAddressBar: true,
flags: [.capsLock],
keyCode: 125
),
1
)
}
func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() {
XCTAssertNil(
browserOmnibarSelectionDeltaForCommandNavigation(
@ -1101,6 +1241,33 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase {
1
)
}
func testCommandNavigationDeltaIgnoresCapsLockModifier() {
XCTAssertEqual(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.control, .capsLock],
chars: "n"
),
1
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.command, .capsLock],
chars: "p"
),
-1
)
}
func testSubmitOnReturnIgnoresCapsLockModifier() {
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: []))
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift]))
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock]))
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock]))
XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock]))
}
}
final class BrowserZoomShortcutActionTests: XCTestCase {
@ -1117,6 +1284,10 @@ final class BrowserZoomShortcutActionTests: XCTestCase {
browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24),
.zoomIn
)
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30),
.zoomIn
)
}
func testZoomOutSupportsMinusAndUnderscoreVariants() {
@ -1195,6 +1366,30 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
}
}
final class GhosttyResponderResolutionTests: XCTestCase {
private final class FocusProbeView: NSView {
override var acceptsFirstResponder: Bool { true }
}
func testResolvesGhosttyViewFromDescendantResponder() {
let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
ghosttyView.addSubview(descendant)
XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView)
}
func testResolvesGhosttyViewFromGhosttyResponder() {
let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView)
}
func testReturnsNilForUnrelatedResponder() {
let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
XCTAssertNil(cmuxOwningGhosttyView(for: view))
}
}
final class CommandPaletteKeyboardNavigationTests: XCTestCase {
func testArrowKeysMoveSelectionWithoutModifiers() {
XCTAssertEqual(
@ -1342,115 +1537,34 @@ final class CommandPaletteRenameSelectionSettingsTests: XCTestCase {
}
final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase {
func testFirstEntryAlwaysPinsToTopWhenScrollable() {
let anchor = ContentView.commandPaletteScrollAnchor(
func testFirstEntryPinsToTopAnchor() {
let anchor = ContentView.commandPaletteScrollPositionAnchor(
selectedIndex: 0,
previousIndex: 1,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 8, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
resultCount: 20
)
XCTAssertEqual(anchor, .top)
XCTAssertEqual(anchor, UnitPoint.top)
}
func testLastEntryAlwaysPinsToBottomWhenScrollable() {
let anchor = ContentView.commandPaletteScrollAnchor(
func testLastEntryPinsToBottomAnchor() {
let anchor = ContentView.commandPaletteScrollPositionAnchor(
selectedIndex: 19,
previousIndex: 18,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 188, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
resultCount: 20
)
XCTAssertEqual(anchor, .bottom)
XCTAssertEqual(anchor, UnitPoint.bottom)
}
func testFullyVisibleMiddleEntryDoesNotScroll() {
let anchor = ContentView.commandPaletteScrollAnchor(
func testMiddleEntryUsesNilAnchorForMinimalScroll() {
let anchor = ContentView.commandPaletteScrollPositionAnchor(
selectedIndex: 6,
previousIndex: 5,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 120, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
resultCount: 20
)
XCTAssertNil(anchor)
}
func testOutOfViewMiddleEntryUsesDirectionForAnchor() {
let downAnchor = ContentView.commandPaletteScrollAnchor(
selectedIndex: 9,
previousIndex: 8,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 210, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
)
XCTAssertEqual(downAnchor, .bottom)
let upAnchor = ContentView.commandPaletteScrollAnchor(
selectedIndex: 8,
previousIndex: 9,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: -6, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
)
XCTAssertEqual(upAnchor, .top)
}
}
final class CommandPaletteEdgeVisibilityCorrectionTests: XCTestCase {
func testTopEdgeReturnsTopWhenNotPinned() {
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
func testEmptyResultsProduceNoAnchor() {
let anchor = ContentView.commandPaletteScrollPositionAnchor(
selectedIndex: 0,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 6, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
)
XCTAssertEqual(anchor, .top)
}
func testBottomEdgeReturnsBottomWhenNotPinned() {
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
selectedIndex: 19,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 170, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
)
XCTAssertEqual(anchor, .bottom)
}
func testPinnedTopAndBottomReturnNil() {
let topAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
selectedIndex: 0,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 0, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
)
XCTAssertNil(topAnchor)
let bottomAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
selectedIndex: 19,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 192, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
)
XCTAssertNil(bottomAnchor)
}
func testMiddleSelectionNeverForcesCorrection() {
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
selectedIndex: 8,
resultCount: 20,
selectedFrame: CGRect(x: 0, y: 96, width: 200, height: 24),
viewportHeight: 216,
contentHeight: 480
resultCount: 0
)
XCTAssertNil(anchor)
}
@ -2192,6 +2306,141 @@ final class TabManagerSurfaceCreationTests: XCTestCase {
}
}
@MainActor
final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftPanelId = workspace.focusedPanelId,
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else {
XCTFail("Expected workspace split setup to succeed")
return
}
// Programmatic split focuses the new right panel by default.
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId)
XCTAssertEqual(
sourcePanel?.id,
leftPanelId,
"Expected inheritance to use the selected terminal in the target pane"
)
}
func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId,
let paneId = workspace.paneId(forPanelId: terminalPanelId),
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
XCTFail("Expected workspace browser setup to succeed")
return
}
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId)
XCTAssertEqual(
sourcePanel?.id,
terminalPanelId,
"Expected inheritance to fall back to a terminal in the pane when browser is selected"
)
}
func testPreferredTerminalPanelWinsWhenProvided() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with a terminal panel")
return
}
let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId)
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
}
func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftTerminalPanelId = workspace.focusedPanelId,
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
XCTFail("Expected split setup to succeed")
return
}
workspace.focusPanel(leftTerminalPanelId)
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId)
XCTAssertEqual(
sourcePanel?.id,
leftTerminalPanelId,
"Expected inheritance to prefer last focused terminal when browser is focused in another pane"
)
}
}
@MainActor
final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
func testUsesFocusedTerminalWhenTerminalIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with focused terminal")
return
}
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
}
func testFallsBackToTerminalWhenBrowserIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId,
let paneId = workspace.paneId(forPanelId: terminalPanelId),
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
XCTFail("Expected selected workspace setup to succeed")
return
}
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(
sourcePanel?.id,
terminalPanelId,
"Expected new workspace inheritance source to resolve to the pane terminal when browser is focused"
)
}
func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftTerminalPanelId = workspace.focusedPanelId,
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
XCTFail("Expected split setup to succeed")
return
}
workspace.focusPanel(leftTerminalPanelId)
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(
sourcePanel?.id,
leftTerminalPanelId,
"Expected workspace inheritance source to use last focused terminal across panes"
)
}
}
@MainActor
final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {
@ -3008,6 +3257,63 @@ final class FinderServicePathResolverTests: XCTestCase {
}
}
final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase {
private func environment(
existingPaths: Set<String>,
homeDirectoryPath: String = "/Users/tester"
) -> TerminalDirectoryOpenTarget.DetectionEnvironment {
TerminalDirectoryOpenTarget.DetectionEnvironment(
homeDirectoryPath: homeDirectoryPath,
fileExistsAtPath: { existingPaths.contains($0) }
)
}
func testAvailableTargetsDetectSystemApplications() {
let env = environment(
existingPaths: [
"/Applications/Visual Studio Code.app",
"/System/Library/CoreServices/Finder.app",
"/System/Applications/Utilities/Terminal.app",
"/Applications/Zed Preview.app",
]
)
let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
XCTAssertTrue(availableTargets.contains(.vscode))
XCTAssertTrue(availableTargets.contains(.finder))
XCTAssertTrue(availableTargets.contains(.terminal))
XCTAssertTrue(availableTargets.contains(.zed))
XCTAssertFalse(availableTargets.contains(.cursor))
}
func testAvailableTargetsFallbackToUserApplications() {
let env = environment(
existingPaths: [
"/Users/tester/Applications/Cursor.app",
"/Users/tester/Applications/Warp.app",
"/Users/tester/Applications/Android Studio.app",
]
)
let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
XCTAssertTrue(availableTargets.contains(.cursor))
XCTAssertTrue(availableTargets.contains(.warp))
XCTAssertTrue(availableTargets.contains(.androidStudio))
XCTAssertFalse(availableTargets.contains(.vscode))
}
func testITerm2DetectsLegacyBundleName() {
let env = environment(existingPaths: ["/Applications/iTerm.app"])
XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env))
}
func testCommandPaletteShortcutsExcludeGenericIDEEntry() {
let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets
XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" }))
XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" }))
}
}
final class BrowserSearchEngineTests: XCTestCase {
func testGoogleSearchURL() throws {
let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world"))
@ -4287,6 +4593,50 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
XCTAssertTrue(state.isHidden)
}
func testWindowResignKeyClearsFocusedTerminalFirstResponder() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let hostedView = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
)
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
hostedView.setVisibleInUI(true)
hostedView.setActive(true)
hostedView.moveFocus()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(
hostedView.isSurfaceViewFirstResponder(),
"Expected terminal surface to be first responder before window blur"
)
NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertFalse(
hostedView.isSurfaceViewFirstResponder(),
"Window blur should force terminal surface to resign first responder"
)
}
func testSearchOverlayMountsAndUnmountsWithSearchState() {
let surface = TerminalSurface(
tabId: UUID(),
@ -4998,6 +5348,38 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase {
}
}
final class BrowserExternalNavigationSchemeTests: XCTestCase {
func testCustomAppSchemesOpenExternally() throws {
let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc"))
let slack = try XCTUnwrap(URL(string: "slack://open"))
let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join"))
let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com"))
XCTAssertTrue(browserShouldOpenURLExternally(discord))
XCTAssertTrue(browserShouldOpenURLExternally(slack))
XCTAssertTrue(browserShouldOpenURLExternally(zoom))
XCTAssertTrue(browserShouldOpenURLExternally(mailto))
}
func testEmbeddedBrowserSchemesStayInWebView() throws {
let https = try XCTUnwrap(URL(string: "https://example.com"))
let http = try XCTUnwrap(URL(string: "http://example.com"))
let about = try XCTUnwrap(URL(string: "about:blank"))
let data = try XCTUnwrap(URL(string: "data:text/plain,hello"))
let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"))
let javascript = try XCTUnwrap(URL(string: "javascript:void(0)"))
let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page"))
XCTAssertFalse(browserShouldOpenURLExternally(https))
XCTAssertFalse(browserShouldOpenURLExternally(http))
XCTAssertFalse(browserShouldOpenURLExternally(about))
XCTAssertFalse(browserShouldOpenURLExternally(data))
XCTAssertFalse(browserShouldOpenURLExternally(blob))
XCTAssertFalse(browserShouldOpenURLExternally(javascript))
XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal))
}
}
final class BrowserHostWhitelistTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!

View file

@ -35,4 +35,31 @@ final class SidebarResizeUITests: XCTestCase {
XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left")
XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset")
}
func testSidebarResizerHasMaximumWidthCap() {
let app = XCUIApplication()
app.launch()
let window = app.windows.firstMatch
XCTAssertTrue(window.waitForExistence(timeout: 5.0))
let elements = app.descendants(matching: .any)
let resizer = elements["SidebarResizer"]
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let farRight = start.withOffset(CGVector(dx: 5000, dy: 0))
start.press(forDuration: 0.1, thenDragTo: farRight)
let windowFrame = window.frame
let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX)
let minimumExpectedRemaining = windowFrame.width * 0.45
XCTAssertGreaterThanOrEqual(
remainingWidth,
minimumExpectedRemaining,
"Expected sidebar max-width clamp to leave substantial terminal width. " +
"remaining=\(remainingWidth), window=\(windowFrame.width)"
)
}
}

View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Regression test for https://github.com/manaflow-ai/cmux/issues/385.
# Ensures self-hosted UI tests are never run for fork pull requests.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml"
EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository"
if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then
echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE"
echo "Expected line:"
echo " $EXPECTED_IF"
exit 1
fi
if ! awk '
/^ ui-tests:/ { in_ui_tests=1; next }
in_ui_tests && /^ [^[:space:]]/ { in_ui_tests=0 }
in_ui_tests && /runs-on: self-hosted/ { saw_self_hosted=1 }
in_ui_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 }
END { exit !(saw_self_hosted && saw_guard) }
' "$WORKFLOW_FILE"; then
echo "FAIL: ui-tests block must keep both self-hosted and fork guard"
exit 1
fi
echo "PASS: ui-tests self-hosted fork guard is present"

View file

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Static regression checks for terminal tiny-pane resize/overflow fixes.
Guards the key invariants for issue #348:
1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds.
2) Surface sizing must prefer live bounds over stale pending values when available.
"""
from __future__ import annotations
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] = []
portal_path = root / "Sources" / "TerminalWindowPortal.swift"
portal_source = portal_path.read_text(encoding="utf-8")
if "hostView.layer?.masksToBounds = true" not in portal_source:
failures.append("WindowTerminalPortal init no longer enables hostView layer clipping")
if "hostView.postsFrameChangedNotifications = true" not in portal_source:
failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications")
if "hostView.postsBoundsChangedNotifications = true" not in portal_source:
failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications")
if "private func synchronizeLayoutHierarchy()" not in portal_source:
failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()")
if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source:
failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()")
if "hostedView.reconcileGeometryNow()" not in extract_block(
portal_source,
"func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)",
):
failures.append("bind() no longer pre-reconciles hosted geometry before attach")
sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)")
for required in [
"let hostBounds = hostView.bounds",
"let clampedFrame = frameInHost.intersection(hostBounds)",
"let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost",
"scheduleDeferredFullSynchronizeAll()",
"hostedView.reconcileGeometryNow()",
"hostedView.refreshSurfaceNow()",
]:
if required not in sync_block:
failures.append(f"terminal portal sync missing: {required}")
terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift"
terminal_view_source = terminal_view_path.read_text(encoding="utf-8")
resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize")
bounds_index = resolved_block.find("let currentBounds = bounds.size")
pending_index = resolved_block.find("if let pending = pendingSurfaceSize")
if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index:
failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize")
update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)")
if "let size = resolvedSurfaceSize(preferred: size)" not in update_block:
failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()")
if failures:
print("FAIL: terminal resize/portal regression guards failed")
for item in failures:
print(f" - {item}")
return 1
print("PASS: terminal resize/portal regression guards are in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit c91868601ef27e673ca884639a724f2d10fcd54d
Subproject commit 2d0d05aad8e1c2c1c56c290718063f9b53408849