Improve terminal hosting depth and workspace mount policy

This commit is contained in:
Lawrence Chen 2026-02-18 19:44:00 -08:00
parent 9e3f5830a8
commit ed7f6301d0
9 changed files with 651 additions and 124 deletions

View file

@ -14,6 +14,7 @@
A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; };
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; };
@ -132,6 +133,7 @@
A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
@ -304,6 +306,7 @@
A5001417 /* WorkspaceContentView.swift */,
A5001014 /* GhosttyConfig.swift */,
A5001015 /* GhosttyTerminalView.swift */,
A5001531 /* TerminalWindowPortal.swift */,
A5001019 /* TerminalController.swift */,
A5001225 /* SocketControlSettings.swift */,
A5001090 /* AppDelegate.swift */,
@ -528,6 +531,7 @@
A5001407 /* WorkspaceContentView.swift in Sources */,
A5001004 /* GhosttyConfig.swift in Sources */,
A5001005 /* GhosttyTerminalView.swift in Sources */,
A5001532 /* TerminalWindowPortal.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */,
A5001093 /* AppDelegate.swift in Sources */,

View file

@ -1687,3 +1687,79 @@ final class MenuBarIconRendererTests: XCTestCase {
XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001)
}
}
final class WorkspaceMountPolicyTests: XCTestCase {
func testDefaultPolicyMountsOnlySelectedWorkspace() {
let a = UUID()
let b = UUID()
let existing: Set<UUID> = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
existing: existing
)
XCTAssertEqual(next, [b])
}
func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() {
let a = UUID()
let b = UUID()
let c = UUID()
let existing: Set<UUID> = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b, c],
selected: c,
existing: existing,
maxMounted: 2
)
XCTAssertEqual(next, [c, a])
}
func testMissingWorkspacesArePruned() {
let a = UUID()
let b = UUID()
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [b, a],
selected: nil,
existing: [a],
maxMounted: 2
)
XCTAssertEqual(next, [a])
}
func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
let a = UUID()
let b = UUID()
let existing: Set<UUID> = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
existing: existing,
maxMounted: 2
)
XCTAssertEqual(next, [b, a])
}
func testMaxMountedIsClampedToAtLeastOne() {
let a = UUID()
let b = UUID()
let existing: Set<UUID> = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b],
selected: nil,
existing: existing,
maxMounted: 0
)
XCTAssertEqual(next, [a])
}
}

View file

@ -1433,6 +1433,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard let self else { return event }
if event.type == .keyDown {
#if DEBUG
if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1"
|| UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")),
event.timestamp > 0 {
let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000)
let delayText = String(format: "%.2f", delayMs)
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")")
#endif

View file

@ -246,8 +246,13 @@ final class FileDropOverlayView: NSView {
return .copy
}
/// Temporarily hides self, hit-tests the window to find the GhosttyNSView under the cursor.
private func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
/// Hit-tests the window to find the GhosttyNSView under the cursor.
func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
if let window,
let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) {
return portalTerminal
}
guard let window, let contentView = window.contentView,
let themeFrame = contentView.superview else { return nil }
isHidden = true
@ -266,6 +271,32 @@ final class FileDropOverlayView: NSView {
var fileDropOverlayKey: UInt8 = 0
enum WorkspaceMountPolicy {
// Keep only the selected workspace mounted to minimize layer-tree traversal.
static let maxMountedWorkspaces = 1
static func nextMountedWorkspaceIds(
current: [UUID],
selected: UUID?,
existing: Set<UUID>,
maxMounted: Int = maxMountedWorkspaces
) -> [UUID] {
let clampedMax = max(1, maxMounted)
var ordered = current.filter { existing.contains($0) }
if let selected, existing.contains(selected) {
ordered.removeAll { $0 == selected }
ordered.insert(selected, at: 0)
}
if ordered.count > clampedMax {
ordered.removeSubrange(clampedMax...)
}
return ordered
}
}
/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support.
func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) {
guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil,
@ -306,6 +337,7 @@ struct ContentView: View {
@State private var isResizerDragging = false
private let sidebarHandleWidth: CGFloat = 6
@State private var selectedTabIds: Set<UUID> = []
@State private var mountedWorkspaceIds: [UUID] = []
@State private var lastSidebarSelectionIndex: Int? = nil
@State private var titlebarText: String = ""
@State private var isFullScreen: Bool = false
@ -384,9 +416,12 @@ struct ContentView: View {
@State private var titlebarPadding: CGFloat = 32
private var terminalContent: some View {
ZStack {
let mountedWorkspaceIdSet = Set(mountedWorkspaceIds)
let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) }
return ZStack {
ZStack {
ForEach(tabManager.tabs) { tab in
ForEach(mountedWorkspaces) { tab in
let isActive = tabManager.selectedTabId == tab.id
WorkspaceContentView(workspace: tab, isTabActive: isActive)
.opacity(isActive ? 1 : 0)
@ -554,6 +589,7 @@ struct ContentView: View {
.background(Color.clear)
.onAppear {
tabManager.applyWindowBackgroundForSelectedTab()
reconcileMountedWorkspaceIds()
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
@ -562,6 +598,7 @@ struct ContentView: View {
}
.onChange(of: tabManager.selectedTabId) { newValue in
tabManager.applyWindowBackgroundForSelectedTab()
reconcileMountedWorkspaceIds(selectedId: newValue)
guard let newValue else { return }
if selectedTabIds.count <= 1 {
selectedTabIds = [newValue]
@ -585,6 +622,7 @@ struct ContentView: View {
}
.onReceive(tabManager.$tabs) { tabs in
let existingIds = Set(tabs.map { $0.id })
reconcileMountedWorkspaceIds(tabs: tabs)
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
@ -688,6 +726,17 @@ struct ContentView: View {
})
}
private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) {
let currentTabs = tabs ?? tabManager.tabs
let existing = Set(currentTabs.map { $0.id })
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: mountedWorkspaceIds,
selected: effectiveSelectedId,
existing: existing
)
}
private func addTab() {
tabManager.addTab()
sidebarSelectionState.selection = .tabs

View file

@ -1522,6 +1522,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
var backgroundColor: NSColor?
private var keySequence: [ghostty_input_trigger_s] = []
private var keyTables: [String] = []
#if DEBUG
private static let keyLatencyProbeEnabled: Bool = {
if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" {
return true
}
return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")
}()
#endif
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
private var windowObserver: NSObjectProtocol?
@ -1867,6 +1875,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var markedText = NSMutableAttributedString()
private var lastPerformKeyEvent: TimeInterval?
#if DEBUG
private func recordKeyLatency(path: String, event: NSEvent) {
guard Self.keyLatencyProbeEnabled else { return }
guard event.timestamp > 0 else { return }
let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000)
let delayText = String(format: "%.2f", delayMs)
dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
}
#endif
// Prevents NSBeep for unimplemented actions from interpretKeyEvents
override func doCommand(by selector: Selector) {
// Intentionally empty - prevents system beep on unhandled key commands
@ -1877,6 +1895,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
guard let fr = window?.firstResponder as? NSView,
fr === self || fr.isDescendant(of: self) else { return false }
guard let surface = ensureSurfaceReadyForInput() else { return false }
#if DEBUG
recordKeyLatency(path: "performKeyEquivalent", event: event)
#endif
#if DEBUG
cmuxWriteChildExitProbe(
@ -1986,6 +2007,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.keyDown(with: event)
return
}
#if DEBUG
recordKeyLatency(path: "keyDown", event: event)
#endif
#if DEBUG
cmuxWriteChildExitProbe(
@ -2930,6 +2954,7 @@ final class GhosttySurfaceScrollView: NSView {
func setVisibleInUI(_ visible: Bool) {
surfaceView.setVisibleInUI(visible)
isHidden = !visible
if !visible {
// If we were focused, yield first responder.
if let window, let fr = window.firstResponder as? NSView,
@ -3578,22 +3603,38 @@ struct GhosttyTerminalView: NSViewRepresentable {
var onFocus: ((UUID) -> Void)? = nil
var onTriggerFlash: (() -> Void)? = nil
/// SwiftUI can create NSViewRepresentable containers that are not yet inserted into a
/// window (or never inserted at all) during bonsplit structural updates. We must avoid
/// re-parenting the hosted terminal view into an off-window container, since it can get
/// "stuck" there and leave the visible terminal blank/frozen.
private final class HostContainerView: NSView {
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard window != nil else { return }
onDidMoveToWindow?()
onGeometryChanged?()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
onGeometryChanged?()
}
override func layout() {
super.layout()
onGeometryChanged?()
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
onGeometryChanged?()
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
onGeometryChanged?()
}
}
final class Coordinator {
var constraints: [NSLayoutConstraint] = []
var attachGeneration: Int = 0
// Track the latest desired state so attach retries can re-apply focus after re-parenting.
var desiredIsActive: Bool = true
@ -3606,57 +3647,15 @@ struct GhosttyTerminalView: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let container = HostContainerView()
container.wantsLayer = true
container.wantsLayer = false
return container
}
private static func attachHostedView(_ hostedView: GhosttySurfaceScrollView, to host: NSView, coordinator: Coordinator) {
// Avoid implicit animations during reparenting and constraint updates. Even a single
// CoreAnimation scale/bounds animation can produce a 1-frame "blank" or stretched
// compositor frame when the IOSurface-backed layer is resized or moved.
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0
ctx.allowsImplicitAnimation = false
CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
// Remove any stale content views in the host, but avoid unnecessarily removing
// the hosted terminal view if it is already attached.
for v in host.subviews where v !== hostedView {
v.removeFromSuperview()
}
if hostedView.superview !== host {
hostedView.removeFromSuperview()
host.addSubview(hostedView)
}
hostedView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.deactivate(coordinator.constraints)
coordinator.constraints = [
hostedView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
hostedView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
hostedView.topAnchor.constraint(equalTo: host.topAnchor),
hostedView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
]
NSLayoutConstraint.activate(coordinator.constraints)
host.needsLayout = true
host.layoutSubtreeIfNeeded()
}
// Re-apply visible/active state after re-parenting so focus/occlusion requests run with
// a valid window.
// Without this, a focus attempt issued while the hosted view is off-window can time out,
// leaving the visible terminal unfocused (keys appear to go to the wrong surface).
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
hostedView.setActive(coordinator.desiredIsActive)
}
func updateNSView(_ nsView: NSView, context: Context) {
let hostedView = terminalSurface.hostedView
context.coordinator.desiredIsActive = isActive
context.coordinator.desiredIsVisibleInUI = isVisibleInUI
let coordinator = context.coordinator
coordinator.desiredIsActive = isActive
coordinator.desiredIsVisibleInUI = isVisibleInUI
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
hostedView.attachSurface(terminalSurface)
@ -3665,52 +3664,52 @@ struct GhosttyTerminalView: NSViewRepresentable {
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
hostedView.setTriggerFlashHandler(onTriggerFlash)
if hostedView.superview !== nsView {
context.coordinator.attachGeneration += 1
let generation = context.coordinator.attachGeneration
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
// If this container isn't in a window yet, defer attaching until it is.
// Importantly: do NOT detach the hosted view from its current superview
// until we have a valid window, otherwise it can disappear and become
// "stuck" in an off-window container.
if let host = nsView as? HostContainerView {
host.onDidMoveToWindow = { [weak coordinator = context.coordinator, weak host, weak hostedView] in
guard let coordinator, coordinator.attachGeneration == generation else { return }
guard let host, let hostedView else { return }
guard host.window != nil else { return }
Self.attachHostedView(hostedView, to: host, coordinator: coordinator)
}
if let host = nsView as? HostContainerView {
host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in
guard let host, let hostedView, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard host.window != nil else { return }
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
to: host,
visibleInUI: coordinator.desiredIsVisibleInUI
)
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
hostedView.setActive(coordinator.desiredIsActive)
}
host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in
guard let host, let hostedView, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
to: host,
visibleInUI: coordinator.desiredIsVisibleInUI
)
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
}
if nsView.window != nil {
Self.attachHostedView(hostedView, to: nsView, coordinator: context.coordinator)
}
} else {
context.coordinator.attachGeneration += 1
if let host = nsView as? HostContainerView {
host.onDidMoveToWindow = nil
if host.window != nil {
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
to: host,
visibleInUI: coordinator.desiredIsVisibleInUI
)
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
}
}
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachGeneration += 1
NSLayoutConstraint.deactivate(coordinator.constraints)
coordinator.constraints.removeAll()
if let host = nsView as? HostContainerView {
host.onDidMoveToWindow = nil
host.onGeometryChanged = nil
}
// Avoid proactively detaching the hosted terminal view during SwiftUI structural updates.
// When bonsplit rearranges panes, SwiftUI can dismantle the "old" container before the
// "new" container has re-parented the hosted view; removing it here creates a visible
// transient blank (and can strand the view off-window if the re-attach is missed).
let hasHostedTerminal = nsView.subviews.contains(where: { $0 is GhosttySurfaceScrollView })
if !hasHostedTerminal {
nsView.subviews.forEach { $0.removeFromSuperview() }
}
nsView.subviews.forEach { $0.removeFromSuperview() }
}
}

View file

@ -6291,6 +6291,10 @@ class TerminalController {
guard let parsed = parseShortcutCombo(combo) else {
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
}
// Stamp at socket-handler arrival so event.timestamp includes any wait
// before the main-thread event dispatch.
let requestTimestamp = ProcessInfo.processInfo.systemUptime
var result = "ERROR: Failed to create event"
DispatchQueue.main.sync {
@ -6305,11 +6309,11 @@ class TerminalController {
targetWindow.makeKeyAndOrderFront(nil)
}
let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0
guard let event = NSEvent.keyEvent(
guard let keyDownEvent = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: parsed.modifierFlags,
timestamp: ProcessInfo.processInfo.systemUptime,
timestamp: requestTimestamp,
windowNumber: windowNumber,
context: nil,
characters: parsed.characters,
@ -6320,14 +6324,29 @@ class TerminalController {
result = "ERROR: NSEvent.keyEvent returned nil"
return
}
let keyUpEvent = NSEvent.keyEvent(
with: .keyUp,
location: .zero,
modifierFlags: parsed.modifierFlags,
timestamp: requestTimestamp + 0.0001,
windowNumber: windowNumber,
context: nil,
characters: parsed.characters,
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
isARepeat: false,
keyCode: parsed.keyCode
)
// Socket-driven shortcut simulation should reuse the exact same matching logic as the
// app-level shortcut monitor (so tests are hermetic), while still falling back to the
// normal responder chain for plain typing.
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: event) {
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) {
result = "OK"
return
}
NSApp.sendEvent(event)
NSApp.sendEvent(keyDownEvent)
if let keyUpEvent {
NSApp.sendEvent(keyUpEvent)
}
result = "OK"
}
return result
@ -6447,34 +6466,20 @@ class TerminalController {
let contentView = window.contentView,
let themeFrame = contentView.superview else { return }
// Compute the point in contentView's own coordinate system.
// NSHostingView is flipped: (0,0) = top-left, matching our API.
let contentPoint = NSPoint(
x: contentView.bounds.width * nx,
y: contentView.bounds.height * ny
// Convert normalized top-left coordinates into a window point.
let pointInTheme = NSPoint(
x: contentView.frame.minX + (contentView.bounds.width * nx),
y: contentView.frame.maxY - (contentView.bounds.height * ny)
)
let windowPoint = themeFrame.convert(pointInTheme, to: nil)
// hitTest expects the point in the receiver's superview's (themeFrame's)
// coordinate system. Use convert to handle the coordinate transform.
let hitPoint = contentView.convert(contentPoint, to: themeFrame)
// Temporarily hide the overlay so it doesn't intercept the hit test.
let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView
overlay?.isHidden = true
let hitView = contentView.hitTest(hitPoint)
overlay?.isHidden = false
var current: NSView? = hitView
while let view = current {
if let terminal = view as? GhosttyNSView,
let surfaceId = terminal.terminalSurface?.id {
result = surfaceId.uuidString.uppercased()
return
}
current = view.superview
if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView,
let terminal = overlay.terminalUnderPoint(windowPoint),
let surfaceId = terminal.terminalSurface?.id {
result = surfaceId.uuidString.uppercased()
return
}
result = "none"
}
return result
@ -9205,6 +9210,25 @@ class TerminalController {
return "OK Refreshed \(refreshedCount) surfaces"
}
private func viewDepth(of view: NSView, maxDepth: Int = 128) -> Int {
var depth = 0
var current: NSView? = view
while let v = current, depth < maxDepth {
current = v.superview
depth += 1
}
return depth
}
private func isPortalHosted(_ view: NSView) -> Bool {
var current: NSView? = view
while let v = current {
if v is WindowTerminalHostView { return true }
current = v.superview
}
return false
}
private func surfaceHealth(_ tabArg: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var result = ""
@ -9219,7 +9243,9 @@ class TerminalController {
let type = panel.panelType.rawValue
if let tp = panel as? TerminalPanel {
let inWindow = tp.surface.isViewInWindow
return "\(index): \(panelId) type=\(type) in_window=\(inWindow)"
let portalHosted = isPortalHosted(tp.hostedView)
let depth = viewDepth(of: tp.hostedView)
return "\(index): \(panelId) type=\(type) in_window=\(inWindow) portal=\(portalHosted) view_depth=\(depth)"
} else if let bp = panel as? BrowserPanel {
let inWindow = bp.webView.window != nil
return "\(index): \(panelId) type=\(type) in_window=\(inWindow)"

View file

@ -0,0 +1,293 @@
import AppKit
import ObjectiveC
private var cmuxWindowTerminalPortalKey: UInt8 = 0
final class WindowTerminalHostView: NSView {
override var isOpaque: Bool { false }
}
@MainActor
final class WindowTerminalPortal: NSObject {
private weak var window: NSWindow?
private let hostView = WindowTerminalHostView(frame: .zero)
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var installConstraints: [NSLayoutConstraint] = []
private struct Entry {
weak var hostedView: GhosttySurfaceScrollView?
weak var anchorView: NSView?
var visibleInUI: Bool
}
private var entriesByHostedId: [ObjectIdentifier: Entry] = [:]
private var hostedByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
init(window: NSWindow) {
self.window = window
super.init()
hostView.wantsLayer = false
hostView.translatesAutoresizingMaskIntoConstraints = false
_ = ensureInstalled()
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window else { return false }
guard let (container, reference) = installationTarget(for: window) else { return false }
if hostView.superview !== container ||
installedContainerView !== container ||
installedReferenceView !== reference {
NSLayoutConstraint.deactivate(installConstraints)
installConstraints.removeAll()
hostView.removeFromSuperview()
container.addSubview(hostView, positioned: .above, relativeTo: reference)
installConstraints = [
hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor),
hostView.trailingAnchor.constraint(equalTo: reference.trailingAnchor),
hostView.topAnchor.constraint(equalTo: reference.topAnchor),
hostView.bottomAnchor.constraint(equalTo: reference.bottomAnchor),
]
NSLayoutConstraint.activate(installConstraints)
installedContainerView = container
installedReferenceView = reference
} else {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
// Keep the drag/mouse forwarding overlay above portal-hosted terminal views.
if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView,
overlay.superview === container {
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
}
return true
}
private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
guard let contentView = window.contentView else { return nil }
// If NSGlassEffectView wraps the original content view, install inside the glass view
// so terminals are above the glass background but below SwiftUI content.
if contentView.className == "NSGlassEffectView",
let foreground = contentView.subviews.first(where: { $0 !== hostView }) {
return (contentView, foreground)
}
guard let themeFrame = contentView.superview else { return nil }
return (themeFrame, contentView)
}
private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
if view.isHidden { return true }
var current = view.superview
while let v = current {
if v.isHidden { return true }
current = v.superview
}
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> 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
}
func detachHostedView(withId hostedId: ObjectIdentifier) {
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
if let anchor = entry.anchorView {
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
if let hostedView = entry.hostedView, hostedView.superview === hostView {
hostedView.removeFromSuperview()
}
}
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) {
guard ensureInstalled() else { return }
let hostedId = ObjectIdentifier(hostedView)
let anchorId = ObjectIdentifier(anchorView)
if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId {
detachHostedView(withId: previousHostedId)
}
if let oldEntry = entriesByHostedId[hostedId],
let oldAnchor = oldEntry.anchorView,
oldAnchor !== anchorView {
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor))
}
hostedByAnchorId[anchorId] = hostedId
entriesByHostedId[hostedId] = Entry(
hostedView: hostedView,
anchorView: anchorView,
visibleInUI: visibleInUI
)
if hostedView.superview !== hostView {
hostedView.removeFromSuperview()
hostView.addSubview(hostedView)
}
synchronizeHostedView(withId: hostedId)
pruneDeadEntries()
}
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return }
synchronizeHostedView(withId: hostedId)
}
private func synchronizeHostedView(withId hostedId: ObjectIdentifier) {
guard ensureInstalled() else { return }
guard let entry = entriesByHostedId[hostedId] else { return }
guard let hostedView = entry.hostedView else {
entriesByHostedId.removeValue(forKey: hostedId)
return
}
guard let anchorView = entry.anchorView, let window else {
hostedView.isHidden = true
return
}
guard anchorView.window === window else {
hostedView.isHidden = true
return
}
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil)
let shouldHide =
!entry.visibleInUI ||
Self.isHiddenOrAncestorHidden(anchorView) ||
frameInHost.width <= 1 ||
frameInHost.height <= 1
let oldFrame = hostedView.frame
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = frameInHost
CATransaction.commit()
if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 ||
abs(oldFrame.size.height - frameInHost.size.height) > 0.5 {
hostedView.reconcileGeometryNow()
}
}
if hostedView.isHidden != shouldHide {
hostedView.isHidden = shouldHide
}
}
private func pruneDeadEntries() {
let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in
if entry.hostedView == nil {
if let anchor = entry.anchorView {
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
return hostedId
}
if entry.anchorView == nil {
entry.hostedView?.isHidden = true
}
return nil
}
for hostedId in deadHostedIds {
entriesByHostedId.removeValue(forKey: hostedId)
}
let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in
entry.anchorView.map { ObjectIdentifier($0) }
})
hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) }
}
func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
// Restrict hit-testing to currently mapped entries so stale detached views
// can't steal file-drop/mouse routing.
for subview in hostView.subviews.reversed() {
guard let hostedView = subview as? GhosttySurfaceScrollView else { continue }
let hostedId = ObjectIdentifier(hostedView)
guard entriesByHostedId[hostedId] != nil else { continue }
guard !hostedView.isHidden else { continue }
guard hostedView.frame.contains(point) else { continue }
let localPoint = hostedView.convert(point, from: hostView)
return hostedView.hitTest(localPoint) ?? hostedView
}
return nil
}
func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
guard let hitView = viewAtWindowPoint(windowPoint) else { return nil }
var current: NSView? = hitView
while let view = current {
if let terminal = view as? GhosttyNSView { return terminal }
current = view.superview
}
return nil
}
}
@MainActor
enum TerminalWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
private static func portal(for window: NSWindow) -> WindowTerminalPortal {
if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal {
portalsByWindowId[ObjectIdentifier(window)] = existing
return existing
}
let portal = WindowTerminalPortal(window: window)
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
portalsByWindowId[ObjectIdentifier(window)] = portal
return portal
}
static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) {
guard let window = anchorView.window else { return }
let windowId = ObjectIdentifier(window)
let hostedId = ObjectIdentifier(hostedView)
let nextPortal = portal(for: window)
if let oldWindowId = hostedToWindowId[hostedId],
oldWindowId != windowId {
portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId)
}
nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI)
hostedToWindowId[hostedId] = windowId
}
static func synchronizeForAnchor(_ anchorView: NSView) {
guard let window = anchorView.window else { return }
let portal = portal(for: window)
portal.synchronizeHostedViewForAnchor(anchorView)
}
static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? {
let portal = portal(for: window)
return portal.viewAtWindowPoint(windowPoint)
}
static func terminalViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> GhosttyNSView? {
let portal = portal(for: window)
return portal.terminalViewAtWindowPoint(windowPoint)
}
}

View file

@ -997,7 +997,8 @@ class cmux:
def surface_health(self, workspace: Union[str, int, None] = None) -> List[dict]:
"""
Check view health of all surfaces in a workspace.
Returns list of dicts with keys: index, id, type, in_window.
Returns list of dicts with keys: index, id, type, in_window, plus any
extra key=value fields returned by the daemon.
"""
arg = "" if workspace is None else str(workspace)
response = self._send_command(f"surface_health {arg}".rstrip())
@ -1013,14 +1014,36 @@ class cmux:
continue
index = int(parts[0].rstrip(":"))
surface_id = parts[1]
panel_type = parts[2].split("=", 1)[1] if "=" in parts[2] else "unknown"
in_window = parts[3].split("=", 1)[1] == "true" if "=" in parts[3] else False
surfaces.append({
kv: dict[str, str] = {}
for token in parts[2:]:
if "=" not in token:
continue
key, value = token.split("=", 1)
kv[key] = value
panel_type = kv.get("type", "unknown")
in_window = kv.get("in_window", "false") == "true"
row: dict = {
"index": index,
"id": surface_id,
"type": panel_type,
"in_window": in_window,
})
}
for key, value in kv.items():
if key in {"type", "in_window"}:
continue
if value == "true":
row[key] = True
elif value == "false":
row[key] = False
elif value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
row[key] = int(value)
else:
row[key] = value
surfaces.append(row)
return surfaces

View file

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Regression: terminal views should be portal-hosted near the window root.
This catches regressions where terminal NSViews are reattached deep inside the SwiftUI
hierarchy, which increases Core Animation commit traversal depth and input latency.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def main() -> int:
with cmux(SOCKET_PATH) as c:
c.activate_app()
c.new_workspace()
time.sleep(0.2)
c.new_split("right")
time.sleep(0.8)
health = c.surface_health()
terminals = [row for row in health if row.get("type") == "terminal"]
if len(terminals) < 2:
raise cmuxError(f"expected >=2 terminal surfaces after split, got={terminals}")
for row in terminals:
if not row.get("in_window", False):
raise cmuxError(f"terminal not attached to window: {row}")
if row.get("portal") is not True:
raise cmuxError(f"terminal is not portal-hosted: {row}")
depth = row.get("view_depth")
if not isinstance(depth, int):
raise cmuxError(f"missing view_depth in surface_health: {row}")
if depth > 8:
raise cmuxError(f"terminal view depth too deep ({depth}): {row}")
print("PASS: terminal surfaces are portal-hosted with shallow view depth")
return 0
if __name__ == "__main__":
raise SystemExit(main())