import Foundation import SwiftUI import AppKit import Metal import QuartzCore import Combine import CoreText import Darwin import Sentry import Bonsplit import IOSurface import UniformTypeIdentifiers #if os(macOS) func cmuxShouldUseTransparentBackgroundWindow() -> Bool { let defaults = UserDefaults.standard let sidebarBlendMode = defaults.string(forKey: "sidebarBlendMode") ?? "withinWindow" let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false return sidebarBlendMode == "behindWindow" && bgGlassEnabled && !WindowGlassEffect.isAvailable } func cmuxShouldUseClearWindowBackground(for opacity: Double) -> Bool { cmuxShouldUseTransparentBackgroundWindow() || opacity < 0.999 } private func cmuxTransparentWindowBaseColor() -> NSColor { // A tiny non-zero alpha matches Ghostty's window compositing behavior on macOS and // avoids visual artifacts that can happen with a fully clear window background. NSColor.white.withAlphaComponent(0.001) } #endif #if DEBUG private func cmuxChildExitProbePath() -> String? { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1", let path = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], !path.isEmpty else { return nil } return path } private func cmuxLoadChildExitProbe(at path: String) -> [String: String] { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return [:] } return object } private func cmuxWriteChildExitProbe(_ updates: [String: String], increments: [String: Int] = [:]) { guard let path = cmuxChildExitProbePath() else { return } var payload = cmuxLoadChildExitProbe(at: path) for (key, by) in increments { let current = Int(payload[key] ?? "") ?? 0 payload[key] = String(current + by) } for (key, value) in updates { payload[key] = value } guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return } try? out.write(to: URL(fileURLWithPath: path), options: .atomic) } private func cmuxScalarHex(_ value: String?) -> String { guard let value else { return "" } return value.unicodeScalars .map { String(format: "%04X", $0.value) } .joined(separator: ",") } #endif enum GhosttyPasteboardHelper { private static let selectionPasteboard = NSPasteboard( name: NSPasteboard.Name("com.mitchellh.ghostty.selection") ) private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text") private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" private static let temporaryImageFilenamePrefix = "clipboard-" private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!) private static let temporaryImageOwnershipLock = NSLock() private static var ownedTemporaryImagePaths: Set = [] static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? { switch location { case GHOSTTY_CLIPBOARD_STANDARD: return .general case GHOSTTY_CLIPBOARD_SELECTION: return selectionPasteboard default: return nil } } static func stringContents(from pasteboard: NSPasteboard) -> String? { if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty { return urls .map { $0.isFileURL ? escapeForShell($0.path) : $0.absoluteString } .joined(separator: " ") } if let value = pasteboard.string(forType: .string) { return value } if let value = pasteboard.string(forType: utf8PlainTextType) { return value } if hasImageData(in: pasteboard), let html = pasteboard.string(forType: .html), htmlHasNoVisibleText(html) { return nil } if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) { return htmlText } if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) { return rtfText } return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd) } static func hasString(for location: ghostty_clipboard_e) -> Bool { guard let pasteboard = pasteboard(for: location) else { return false } let types = pasteboard.types ?? [] if types.contains(.fileURL) || types.contains(.string) || types.contains(utf8PlainTextType) || types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) { return true } return hasImageData(in: pasteboard) } static func writeString(_ string: String, to location: ghostty_clipboard_e) { guard let pasteboard = pasteboard(for: location) else { return } pasteboard.clearContents() pasteboard.setString(string, forType: .string) } static func escapeForShell(_ value: String) -> String { if value.contains(where: { $0 == "\n" || $0 == "\r" }) { return shellSingleQuoted(value) } var result = value for char in shellEscapeCharacters { result = result.replacingOccurrences(of: String(char), with: "\\\(char)") } return result } private static func shellSingleQuoted(_ value: String) -> String { let escaped = value.replacingOccurrences(of: "'", with: "'\\''") return "'\(escaped)'" } private static func attributedStringContents( from pasteboard: NSPasteboard, type: NSPasteboard.PasteboardType, documentType: NSAttributedString.DocumentType ) -> String? { let attributed = attributedString( from: pasteboard, type: type, documentType: documentType ) let sanitized = attributed?.string .split(separator: objectReplacementCharacter, omittingEmptySubsequences: false) .joined(separator: " ") .trimmingCharacters(in: .whitespacesAndNewlines) guard let sanitized, !sanitized.isEmpty else { return nil } return sanitized } private static func attributedString( from pasteboard: NSPasteboard, type: NSPasteboard.PasteboardType, documentType: NSAttributedString.DocumentType ) -> NSAttributedString? { let data = pasteboard.data(forType: type) ?? pasteboard.string(forType: type)?.data(using: .utf8) guard let data else { return nil } return try? NSAttributedString( data: data, options: [ .documentType: documentType, .characterEncoding: String.Encoding.utf8.rawValue ], documentAttributes: nil ) } private static func rtfdAttachmentImageRepresentation( in pasteboard: NSPasteboard ) -> (data: Data, fileExtension: String)? { guard let attributed = attributedString( from: pasteboard, type: .rtfd, documentType: .rtfd ) else { return nil } var result: (data: Data, fileExtension: String)? attributed.enumerateAttribute( .attachment, in: NSRange(location: 0, length: attributed.length) ) { value, _, stop in guard let attachment = value as? NSTextAttachment else { return } if let fileWrapper = attachment.fileWrapper, let data = fileWrapper.regularFileContents, let imageRepresentation = imageAttachmentRepresentation( data: data, preferredFilename: fileWrapper.preferredFilename ) { result = imageRepresentation stop.pointee = true } } return result } private static func imageAttachmentRepresentation( data: Data, preferredFilename: String? ) -> (data: Data, fileExtension: String)? { let pathExtension = (preferredFilename as NSString?)?.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if let type = !pathExtension.isEmpty ? UTType(filenameExtension: pathExtension) : nil, type.conforms(to: .image), let fileExtension = type.preferredFilenameExtension ?? nonEmpty(pathExtension) { return (data, fileExtension) } guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), let typeIdentifier = CGImageSourceGetType(imageSource) as String?, let type = UTType(typeIdentifier), type.conforms(to: .image), let fileExtension = type.preferredFilenameExtension else { return nil } return (data, fileExtension) } private static func nonEmpty(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private static func hasImageData(in pasteboard: NSPasteboard) -> Bool { let types = pasteboard.types ?? [] if types.contains(.tiff) || types.contains(.png) { return true } return types.contains { type in guard let utType = UTType(type.rawValue) else { return false } return utType.conforms(to: .image) } } private static func directImageRepresentation( in pasteboard: NSPasteboard ) -> (data: Data, fileExtension: String)? { if let pngData = pasteboard.data(forType: .png) { return (pngData, "png") } for type in pasteboard.types ?? [] { guard type != .png, type != .tiff, let utType = UTType(type.rawValue), utType.conforms(to: .image), let imageData = pasteboard.data(forType: type), let fileExtension = utType.preferredFilenameExtension, !fileExtension.isEmpty else { continue } return (imageData, fileExtension) } return nil } private static func htmlHasNoVisibleText(_ html: String) -> Bool { let withoutComments = html.replacingOccurrences( of: "", with: " ", options: .regularExpression ) let withoutTags = withoutComments.replacingOccurrences( of: "<[^>]+>", with: " ", options: .regularExpression ) let normalized = withoutTags .replacingOccurrences(of: " ", with: " ") .replacingOccurrences(of: " ", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) return normalized.isEmpty } /// When the clipboard contains only image data (or rich text that resolves to /// an attachment-only image), saves it as a temporary image file and returns the /// file URL. Returns nil if the clipboard contains text or no image. static func saveImageFileURLIfNeeded( from pasteboard: NSPasteboard = .general, assumeNoText: Bool = false ) -> URL? { if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } let imageData: Data let fileExtension: String if let directImage = directImageRepresentation(in: pasteboard) { imageData = directImage.data fileExtension = directImage.fileExtension } else if let rtfdAttachment = rtfdAttachmentImageRepresentation(in: pasteboard) { imageData = rtfdAttachment.data fileExtension = rtfdAttachment.fileExtension } else { guard hasImageData(in: pasteboard), let image = NSImage(pasteboard: pasteboard), let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData), let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } imageData = pngData fileExtension = "png" } let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB guard imageData.count <= maxClipboardImageSize else { #if DEBUG dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)") #endif return nil } let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd-HHmmss" formatter.locale = Locale(identifier: "en_US_POSIX") let timestamp = formatter.string(from: Date()) let filename = "\(temporaryImageFilenamePrefix)\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)" let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { try imageData.write(to: fileURL) } catch { #if DEBUG dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") #endif return nil } registerOwnedTemporaryImageFile(fileURL) return fileURL } /// When the clipboard contains only image data (or rich text that resolves to /// an attachment-only image), saves it as a temporary image file and returns the /// shell-escaped file path. Returns nil if the clipboard contains text or no image. static func saveClipboardImageIfNeeded( from pasteboard: NSPasteboard = .general, assumeNoText: Bool = false ) -> String? { saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: assumeNoText) .map { escapeForShell($0.path) } } static func cleanupTransferredTemporaryImageFiles(_ fileURLs: [URL]) { for fileURL in fileURLs { let normalizedURL = fileURL.standardizedFileURL guard normalizedURL.isFileURL, consumeOwnedTemporaryImageFile(normalizedURL) else { continue } try? FileManager.default.removeItem(at: normalizedURL) } } private static func registerOwnedTemporaryImageFile(_ fileURL: URL) { let normalizedPath = fileURL.standardizedFileURL.path temporaryImageOwnershipLock.lock() ownedTemporaryImagePaths.insert(normalizedPath) temporaryImageOwnershipLock.unlock() } private static func consumeOwnedTemporaryImageFile(_ fileURL: URL) -> Bool { let normalizedPath = fileURL.standardizedFileURL.path temporaryImageOwnershipLock.lock() let didOwnFile = ownedTemporaryImagePaths.remove(normalizedPath) != nil temporaryImageOwnershipLock.unlock() return didOwnFile } } #if DEBUG func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? { GhosttyPasteboardHelper.stringContents(from: pasteboard) } func cmuxPasteboardImageFileURLForTesting(_ pasteboard: NSPasteboard) -> URL? { GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard) } func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? { GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard) } #endif enum TerminalOpenURLTarget: Equatable { case embeddedBrowser(URL) case external(URL) var url: URL { switch self { case let .embeddedBrowser(url), let .external(url): return url } } } enum GhosttyDefaultBackgroundUpdateScope: Int { case unscoped = 0 case app = 1 case surface = 2 var logLabel: String { switch self { case .unscoped: return "unscoped" case .app: return "app" case .surface: return "surface" } } } /// Coalesces Ghostty background notifications so consumers only observe /// the latest runtime background for a burst of updates. final class GhosttyDefaultBackgroundNotificationDispatcher { private let coalescer: NotificationBurstCoalescer private let postNotification: ([AnyHashable: Any]) -> Void private var pendingUserInfo: [AnyHashable: Any]? private var pendingEventId: UInt64 = 0 private var pendingSource: String = "unspecified" private let logEvent: ((String) -> Void)? init( delay: TimeInterval = 1.0 / 30.0, logEvent: ((String) -> Void)? = nil, postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in NotificationCenter.default.post( name: .ghosttyDefaultBackgroundDidChange, object: nil, userInfo: userInfo ) } ) { coalescer = NotificationBurstCoalescer(delay: delay) self.logEvent = logEvent self.postNotification = postNotification } func signal(backgroundColor: NSColor, opacity: Double, eventId: UInt64, source: String) { let signalOnMain = { [self] in pendingEventId = eventId pendingSource = source pendingUserInfo = [ GhosttyNotificationKey.backgroundColor: backgroundColor, GhosttyNotificationKey.backgroundOpacity: opacity, GhosttyNotificationKey.backgroundEventId: NSNumber(value: eventId), GhosttyNotificationKey.backgroundSource: source ] logEvent?( "bg notify queued id=\(eventId) source=\(source) color=\(backgroundColor.hexString()) opacity=\(String(format: "%.3f", opacity))" ) coalescer.signal { [self] in guard let userInfo = pendingUserInfo else { return } let eventId = pendingEventId let source = pendingSource pendingUserInfo = nil logEvent?("bg notify flushed id=\(eventId) source=\(source)") logEvent?("bg notify posting id=\(eventId) source=\(source)") postNotification(userInfo) logEvent?("bg notify posted id=\(eventId) source=\(source)") } } if Thread.isMainThread { signalOnMain() } else { DispatchQueue.main.async(execute: signalOnMain) } } } func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) #if DEBUG dlog("link.resolve input=\(trimmed)") #endif guard !trimmed.isEmpty else { #if DEBUG dlog("link.resolve result=nil (empty)") #endif return nil } if NSString(string: trimmed).isAbsolutePath { #if DEBUG dlog("link.resolve result=external(absolutePath) url=\(trimmed)") #endif return .external(URL(fileURLWithPath: trimmed)) } if let parsed = URL(string: trimmed), let scheme = parsed.scheme?.lowercased() { if scheme == "http" || scheme == "https" { guard BrowserInsecureHTTPSettings.normalizeHost(parsed.host ?? "") != nil else { #if DEBUG dlog("link.resolve result=external(invalidHost) url=\(parsed)") #endif return .external(parsed) } #if DEBUG dlog("link.resolve result=embeddedBrowser url=\(parsed)") #endif return .embeddedBrowser(parsed) } #if DEBUG dlog("link.resolve result=external(scheme=\(scheme)) url=\(parsed)") #endif return .external(parsed) } if let webURL = resolveBrowserNavigableURL(trimmed) { guard BrowserInsecureHTTPSettings.normalizeHost(webURL.host ?? "") != nil else { #if DEBUG dlog("link.resolve result=external(bareHost-invalidHost) url=\(webURL)") #endif return .external(webURL) } #if DEBUG dlog("link.resolve result=embeddedBrowser(bareHost) url=\(webURL)") #endif return .embeddedBrowser(webURL) } guard let fallback = URL(string: trimmed) else { #if DEBUG dlog("link.resolve result=nil (unparseable)") #endif return nil } #if DEBUG dlog("link.resolve result=external(fallback) url=\(fallback)") #endif return .external(fallback) } enum TerminalKeyboardCopyModeSelectionMove: String, Equatable { case left case right case up case down case pageUp = "page_up" case pageDown = "page_down" case home case end case beginningOfLine = "beginning_of_line" case endOfLine = "end_of_line" } enum TerminalKeyboardCopyModeAction: Equatable { case exit case startSelection case clearSelection case copyAndExit case copyLineAndExit case scrollLines(Int) case scrollPage(Int) case scrollHalfPage(Int) case scrollToTop case scrollToBottom case jumpToPrompt(Int) case startSearch case searchNext case searchPrevious case adjustSelection(TerminalKeyboardCopyModeSelectionMove) } struct TerminalKeyboardCopyModeInputState: Equatable { var countPrefix: Int? var pendingYankLine = false var pendingG = false mutating func reset() { countPrefix = nil pendingYankLine = false pendingG = false } } enum TerminalKeyboardCopyModeResolution: Equatable { case perform(TerminalKeyboardCopyModeAction, count: Int) case consume } private let terminalKeyboardCopyModeMaxCount = 9_999 private var terminalKeyboardCopyModeIndicatorText: String { String(localized: "ghostty.copy-mode.indicator", defaultValue: "vim") } private var terminalKeyTableIndicatorDefaultText: String { String(localized: "ghostty.key-table.indicator", defaultValue: "key table") } private var terminalKeyTableIndicatorAccessibilityLabel: String { String(localized: "ghostty.key-table.icon.accessibility", defaultValue: "Key table") } private func terminalKeyboardCopyModeClampCount(_ value: Int) -> Int { min(max(value, 1), terminalKeyboardCopyModeMaxCount) } private func terminalKeyTableIndicatorText(_ name: String) -> String { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) switch trimmed.lowercased() { case "", "set": return terminalKeyTableIndicatorDefaultText case "vi", "vim": return terminalKeyboardCopyModeIndicatorText default: let normalized = trimmed .replacingOccurrences(of: "_", with: " ") .replacingOccurrences(of: "-", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) return normalized.isEmpty ? terminalKeyTableIndicatorDefaultText : normalized } } func terminalKeyboardCopyModeInitialViewportRow( rows: Int, imePointY: Double, imeCellHeight: Double, topPadding: Double = 0 ) -> Int { let clampedRows = max(rows, 1) guard imeCellHeight > 0 else { return clampedRows - 1 } // `ghostty_surface_ime_point` returns a top-origin Y coordinate at the // cursor baseline plus one cell-height. Convert that to a zero-based row. let estimatedRow = Int(floor(((imePointY - topPadding) / imeCellHeight) - 1)) return max(0, min(clampedRows - 1, estimatedRow)) } private func terminalKeyboardCopyModeNormalizedModifiers( _ modifierFlags: NSEvent.ModifierFlags ) -> NSEvent.ModifierFlags { modifierFlags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) } private func terminalKeyboardCopyModeChars( _ charactersIgnoringModifiers: String? ) -> String { guard let scalar = charactersIgnoringModifiers?.unicodeScalars.first else { return "" } return String(scalar).lowercased() } func terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: NSEvent.ModifierFlags) -> Bool { let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) return normalized.contains(.command) } func terminalKeyboardCopyModeAction( keyCode: UInt16, charactersIgnoringModifiers: String?, modifierFlags: NSEvent.ModifierFlags, hasSelection: Bool ) -> TerminalKeyboardCopyModeAction? { let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers) if keyCode == 53 { // Escape return .exit } switch keyCode { case 126: // Up return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) case 125: // Down return hasSelection ? .adjustSelection(.down) : .scrollLines(1) case 123: // Left return hasSelection ? .adjustSelection(.left) : nil case 124: // Right return hasSelection ? .adjustSelection(.right) : nil case 116: // Page Up return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1) case 121: // Page Down return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1) case 115: // Home return hasSelection ? .adjustSelection(.home) : .scrollToTop case 119: // End return hasSelection ? .adjustSelection(.end) : .scrollToBottom default: break } if normalized == [.control] { if chars == "u" || chars == "\u{15}" { return hasSelection ? .adjustSelection(.pageUp) : .scrollHalfPage(-1) } if chars == "d" || chars == "\u{04}" { return hasSelection ? .adjustSelection(.pageDown) : .scrollHalfPage(1) } if chars == "b" || chars == "\u{02}" { return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1) } if chars == "f" || chars == "\u{06}" { return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1) } if chars == "y" || chars == "\u{19}" { return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) } if chars == "e" || chars == "\u{05}" { return hasSelection ? .adjustSelection(.down) : .scrollLines(1) } return nil } guard normalized.isEmpty || normalized == [.shift] else { return nil } switch chars { case "q": return .exit case "v": return hasSelection ? .clearSelection : .startSelection case "y": if normalized == [.shift], !hasSelection { return .copyLineAndExit } return hasSelection ? .copyAndExit : nil case "j": return hasSelection ? .adjustSelection(.down) : .scrollLines(1) case "k": return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) case "h": return hasSelection ? .adjustSelection(.left) : nil case "l": return hasSelection ? .adjustSelection(.right) : nil case "g": if normalized == [.shift] { return hasSelection ? .adjustSelection(.end) : .scrollToBottom } // Bare "g" is a prefix key (e.g. gg); handled in resolve. return nil case "0", "^": return hasSelection ? .adjustSelection(.beginningOfLine) : nil case "$", "4": guard chars == "$" || normalized == [.shift] else { return nil } return hasSelection ? .adjustSelection(.endOfLine) : nil case "{", "[": guard chars == "{" || normalized == [.shift] else { return nil } return .jumpToPrompt(-1) case "}", "]": guard chars == "}" || normalized == [.shift] else { return nil } return .jumpToPrompt(1) case "/": return .startSearch case "n": return normalized == [.shift] ? .searchPrevious : .searchNext default: return nil } } func terminalKeyboardCopyModeResolve( keyCode: UInt16, charactersIgnoringModifiers: String?, modifierFlags: NSEvent.ModifierFlags, hasSelection: Bool, state: inout TerminalKeyboardCopyModeInputState ) -> TerminalKeyboardCopyModeResolution { let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers) if keyCode == 53 { // Escape state.reset() return .perform(.exit, count: 1) } if state.pendingYankLine { if chars == "y", normalized.isEmpty || normalized == [.shift] { let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1) state.reset() return .perform(.copyLineAndExit, count: count) } // Only `yy`/`Y` are supported as line-yank operators, so cancel the // pending yank and treat this key as a fresh command. state.pendingYankLine = false } if state.pendingG { if chars == "g", normalized.isEmpty { let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1) let action: TerminalKeyboardCopyModeAction = hasSelection ? .adjustSelection(.home) : .scrollToTop state.reset() return .perform(action, count: count) } // Not `gg`, cancel and treat as fresh command. state.pendingG = false } if normalized.isEmpty, let scalar = chars.unicodeScalars.first, scalar.isASCII, scalar.value >= 48, scalar.value <= 57 { let digit = Int(scalar.value - 48) if digit == 0 { if let currentCount = state.countPrefix { state.countPrefix = terminalKeyboardCopyModeClampCount(currentCount * 10) return .consume } } else { let currentCount = state.countPrefix ?? 0 state.countPrefix = terminalKeyboardCopyModeClampCount((currentCount * 10) + digit) return .consume } } if !hasSelection, chars == "y", normalized.isEmpty { state.pendingYankLine = true return .consume } if chars == "g", normalized.isEmpty { state.pendingG = true return .consume } guard let action = terminalKeyboardCopyModeAction( keyCode: keyCode, charactersIgnoringModifiers: charactersIgnoringModifiers, modifierFlags: modifierFlags, hasSelection: hasSelection ) else { state.reset() return .consume } let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1) state.reset() return .perform(action, count: count) } private final class GhosttySurfaceCallbackContext { weak var surfaceView: GhosttyNSView? weak var terminalSurface: TerminalSurface? let surfaceId: UUID init(surfaceView: GhosttyNSView, terminalSurface: TerminalSurface) { self.surfaceView = surfaceView self.terminalSurface = terminalSurface self.surfaceId = terminalSurface.id } var tabId: UUID? { terminalSurface?.tabId ?? surfaceView?.tabId } var runtimeSurface: ghostty_surface_t? { terminalSurface?.surface ?? surfaceView?.terminalSurface?.surface } } // Minimal Ghostty wrapper for terminal rendering // This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation // MARK: - Ghostty App Singleton class GhosttyApp { static let shared = GhosttyApp() private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() private(set) var app: ghostty_app_t? private(set) var config: ghostty_config_t? /// Coalesce wakeup → tick dispatches. The I/O thread may fire wakeup_cb /// thousands of times per second during bulk output. We only need one /// pending tick on the main queue at any time. private var _tickScheduled = false private let _tickLock = NSLock() private(set) var defaultBackgroundColor: NSColor = .windowBackgroundColor private(set) var defaultBackgroundOpacity: Double = 1.0 private static func resolveBackgroundLogURL( environment: [String: String] = ProcessInfo.processInfo.environment ) -> URL { if let explicitPath = environment["CMUX_DEBUG_BG_LOG"], !explicitPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return URL(fileURLWithPath: explicitPath) } if let debugLogPath = environment["CMUX_DEBUG_LOG"], !debugLogPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let baseURL = URL(fileURLWithPath: debugLogPath) let extensionSeparatorIndex = baseURL.lastPathComponent.lastIndex(of: ".") let stem = extensionSeparatorIndex.map { String(baseURL.lastPathComponent[..<$0]) } ?? baseURL.lastPathComponent let bgName = "\(stem)-bg.log" return baseURL.deletingLastPathComponent().appendingPathComponent(bgName) } return URL(fileURLWithPath: "/tmp/cmux-bg.log") } let backgroundLogEnabled = { if ProcessInfo.processInfo.environment["CMUX_DEBUG_BG"] == "1" { return true } if ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"] != nil { return true } if ProcessInfo.processInfo.environment["GHOSTTYTABS_DEBUG_BG"] == "1" { return true } if UserDefaults.standard.bool(forKey: "cmuxDebugBG") { return true } return UserDefaults.standard.bool(forKey: "GhosttyTabsDebugBG") }() private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() private let backgroundLogStartUptime = ProcessInfo.processInfo.systemUptime private let backgroundLogLock = NSLock() private var backgroundLogSequence: UInt64 = 0 private var appObservers: [NSObjectProtocol] = [] private var bellAudioSound: NSSound? private var backgroundEventCounter: UInt64 = 0 private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped private var defaultBackgroundScopeSource: String = "initialize" private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference? private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher = // Theme chrome should track terminal theme changes in the same frame. // Keep coalescing semantics, but flush in the next main turn instead of waiting ~1 frame. GhosttyDefaultBackgroundNotificationDispatcher(delay: 0, logEvent: { [weak self] message in guard let self, self.backgroundLogEnabled else { return } self.logBackground(message) }) // Scroll lag tracking private(set) var isScrolling = false private var scrollLagSampleCount = 0 private var scrollLagTotalMs: Double = 0 private var scrollLagMaxMs: Double = 0 private let scrollLagThresholdMs: Double = 40 private let scrollLagMinimumSamples = 8 private let scrollLagMinimumAverageMs: Double = 12 private let scrollLagReportCooldownSeconds: TimeInterval = 300 private var lastScrollLagReportUptime: TimeInterval? private var scrollEndTimer: DispatchWorkItem? func markScrollActivity(hasMomentum: Bool, momentumEnded: Bool) { // Cancel any pending scroll-end timer scrollEndTimer?.cancel() scrollEndTimer = nil if momentumEnded { // Trackpad momentum ended - scrolling is done endScrollSession() } else if hasMomentum { // Trackpad scrolling with momentum - wait for momentum to end isScrolling = true } else { // Mouse wheel or non-momentum scroll - use timeout isScrolling = true let timer = DispatchWorkItem { [weak self] in self?.endScrollSession() } scrollEndTimer = timer DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: timer) } } private func endScrollSession() { guard isScrolling else { return } isScrolling = false // Report accumulated lag stats if any exceeded threshold if scrollLagSampleCount > 0 { let avgLag = scrollLagTotalMs / Double(scrollLagSampleCount) let maxLag = scrollLagMaxMs let samples = scrollLagSampleCount let threshold = scrollLagThresholdMs let nowUptime = ProcessInfo.processInfo.systemUptime if Self.shouldCaptureScrollLagEvent( samples: samples, averageMs: avgLag, maxMs: maxLag, thresholdMs: threshold, minimumSamples: scrollLagMinimumSamples, minimumAverageMs: scrollLagMinimumAverageMs, nowUptime: nowUptime, lastReportedUptime: lastScrollLagReportUptime, cooldown: scrollLagReportCooldownSeconds ) { if TelemetrySettings.enabledForCurrentLaunch { SentrySDK.capture(message: "Scroll lag detected") { scope in scope.setLevel(.warning) scope.setContext(value: [ "samples": samples, "avg_ms": String(format: "%.2f", avgLag), "max_ms": String(format: "%.2f", maxLag), "threshold_ms": threshold ], key: "scroll_lag") } } lastScrollLagReportUptime = nowUptime } // Reset stats scrollLagSampleCount = 0 scrollLagTotalMs = 0 scrollLagMaxMs = 0 } } private init() { initializeGhostty() } #if DEBUG private static let initLogPath = "/tmp/cmux-ghostty-init.log" private static func initLog(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: initLogPath) { handle.seekToEndOfFile() handle.write(Data(line.utf8)) handle.closeFile() } else { FileManager.default.createFile(atPath: initLogPath, contents: line.data(using: .utf8)) } } private static func dumpConfigDiagnostics(_ config: ghostty_config_t, label: String) { let count = Int(ghostty_config_diagnostics_count(config)) guard count > 0 else { initLog("ghostty diagnostics (\(label)): none") return } initLog("ghostty diagnostics (\(label)): count=\(count)") for i in 0.. 0 else { return } let buffer = UnsafeBufferPointer(start: content, count: Int(len)) var fallback: String? for item in buffer { guard let dataPtr = item.data else { continue } let value = String(cString: dataPtr) if let mimePtr = item.mime { let mime = String(cString: mimePtr) if mime.hasPrefix("text/plain") { GhosttyPasteboardHelper.writeString(value, to: location) return } } if fallback == nil { fallback = value } } if let fallback { GhosttyPasteboardHelper.writeString(fallback, to: location) } } runtimeConfig.close_surface_cb = { userdata, needsConfirmClose in guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return } let callbackSurfaceId = callbackContext.surfaceId let callbackTabId = callbackContext.tabId #if DEBUG cmuxWriteChildExitProbe( [ "probeCloseSurfaceNeedsConfirm": needsConfirmClose ? "1" : "0", "probeCloseSurfaceTabId": callbackTabId?.uuidString ?? "", "probeCloseSurfaceSurfaceId": callbackSurfaceId.uuidString, ], increments: ["probeCloseSurfaceCbCount": 1] ) #endif DispatchQueue.main.async { guard let app = AppDelegate.shared else { return } // Close requests must be resolved by the callback's workspace/surface IDs only. // If the mapping is already gone (duplicate/stale callback), ignore it. if let callbackTabId, let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), workspace.panels[callbackSurfaceId] != nil { if needsConfirmClose { manager.closeRuntimeSurfaceWithConfirmation( tabId: callbackTabId, surfaceId: callbackSurfaceId ) } else { manager.closeRuntimeSurface( tabId: callbackTabId, surfaceId: callbackSurfaceId ) } } } } // Create app if let created = ghostty_app_new(&runtimeConfig, primaryConfig) { self.app = created self.config = primaryConfig } else { #if DEBUG Self.initLog("ghostty_app_new(primary) failed; attempting fallback config") Self.dumpConfigDiagnostics(primaryConfig, label: "primary") #endif // If the user config is invalid, prefer a minimal fallback configuration so // cmux still launches with working terminals. ghostty_config_free(primaryConfig) guard let fallbackConfig = ghostty_config_new() else { print("Failed to create ghostty fallback config") return } loadInlineGhosttyConfig( "macos-background-from-layer = true", into: fallbackConfig, prefix: "cmux-layer-bg", logLabel: "layer background (fallback)" ) ghostty_config_finalize(fallbackConfig) updateDefaultBackground(from: fallbackConfig, source: "initialize.fallbackConfig") guard let created = ghostty_app_new(&runtimeConfig, fallbackConfig) else { #if DEBUG Self.initLog("ghostty_app_new(fallback) failed") Self.dumpConfigDiagnostics(fallbackConfig, label: "fallback") #endif print("Failed to create ghostty app") ghostty_config_free(fallbackConfig) return } self.app = created self.config = fallbackConfig } // Notify observers that a usable config is available (initial load). lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) #if os(macOS) if let app { ghostty_app_set_focus(app, NSApp.isActive) } appObservers.append(NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in guard let app = self?.app else { return } ghostty_app_set_focus(app, true) }) appObservers.append(NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main ) { [weak self] _ in guard let app = self?.app else { return } ghostty_app_set_focus(app, false) }) #endif } private func loadInlineGhosttyConfig( _ contents: String, into config: ghostty_config_t, prefix: String, logLabel: String ) { let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let tmpURL = FileManager.default.temporaryDirectory .appendingPathComponent("\(prefix)-\(UUID().uuidString).conf") do { try trimmed.write(to: tmpURL, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: tmpURL) } tmpURL.path.withCString { path in ghostty_config_load_file(config, path) } } catch { #if DEBUG dlog("ghostty.config.inlineLoad.failed label=\(logLabel) error=\(error.localizedDescription)") #endif } } private func loadCopyOnSelectOverride(_ config: ghostty_config_t) { loadInlineGhosttyConfig( TerminalCopyOnSelectSettings.overrideConfigLine(), into: config, prefix: "cmux-copy-on-select", logLabel: "copy-on-select override" ) } private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { ghostty_config_load_default_files(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) loadCmuxAppSupportGhosttyConfigIfNeeded(config) loadCopyOnSelectOverride(config) loadCJKFontFallbackIfNeeded(config) // cmux provides the terminal background via backgroundView (CALayer) // instead of the GPU full-screen bg pass, so the layer can provide // instant coverage during sidebar toggle and other layout transitions. loadInlineGhosttyConfig( "macos-background-from-layer = true", into: config, prefix: "cmux-layer-bg", logLabel: "layer background" ) ghostty_config_finalize(config) } /// When the user has not configured `font-codepoint-map` for CJK ranges /// and has not already provided an explicit multi-entry `font-family` /// fallback chain, Ghostty's `CTFontCollection` scoring may pick an /// inappropriate fallback font for Hiragana, Katakana, and CJK symbols. /// The scoring prioritizes monospace fonts, so decorative fonts with /// monospace attributes (e.g. AB_appare from Adobe CC, or LingWai) can be /// selected depending on what is installed. This injects a sensible /// default based on the system's preferred languages without overriding /// user-managed fallback chains or configured fonts that already cover /// the affected CJK ranges. /// /// See: https://github.com/manaflow-ai/cmux/pull/1017 private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) { guard let mappings = Self.autoInjectedCJKFontMappings() else { return } let lines = mappings.map { range, font in "font-codepoint-map = \(range)=\(font)" }.joined(separator: "\n") loadInlineGhosttyConfig( lines, into: config, prefix: "cmux-cjk-font-fallback", logLabel: "CJK font fallback" ) } /// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms). private static let sharedCJKRanges = [ "U+3000-U+303F", // CJK Symbols and Punctuation "U+4E00-U+9FFF", // CJK Unified Ideographs "U+F900-U+FAFF", // CJK Compatibility Ideographs "U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms "U+3400-U+4DBF", // CJK Unified Ideographs Extension A ] /// Unicode ranges specific to Japanese (kana). private static let japaneseRanges = [ "U+3040-U+309F", // Hiragana "U+30A0-U+30FF", // Katakana ] /// Representative scalars used to detect whether the configured primary /// font already covers the ranges cmux would otherwise auto-map. private static let cjkCoverageSampleCharactersByRange: [String: [UniChar]] = [ "U+3000-U+303F": [0x3001, 0x300C], "U+4E00-U+9FFF": [0x4E00, 0x65E5, 0x6C34], "U+F900-U+FAFF": [0xF900], "U+FF00-U+FFEF": [0xFF10, 0xFF21], "U+3400-U+4DBF": [0x3400], "U+1100-U+11FF": [0x1100, 0x1161], "U+3130-U+318F": [0x3131, 0x314F], "U+3040-U+309F": [0x3042, 0x3093], "U+30A0-U+30FF": [0x30A2, 0x30F3], "U+AC00-U+D7AF": [0xAC00, 0xD55C], ] private struct UserFontConfigSummary { var containsCodepointMap = false var effectiveFontFamilies: [String] = [] var hasExplicitFontFamilyFallbackChain: Bool { effectiveFontFamilies.count > 1 } mutating func applyFontCodepointMap(_ value: String) { if value.isEmpty { containsCodepointMap = false return } guard value.contains("=") else { return } containsCodepointMap = true } mutating func recordFontFamily(_ value: String) { if value.isEmpty { effectiveFontFamilies.removeAll() return } guard !effectiveFontFamilies.contains(value) else { return } effectiveFontFamilies.append(value) } } /// Returns (range, font) pairs for CJK font fallback based on the system's /// preferred languages, or nil if no CJK language is detected. Each language /// only maps its own script ranges to avoid assigning glyphs to a font that /// lacks coverage (e.g. Hangul to Hiragino Sans). static func cjkFontMappings( preferredLanguages: [String] = Locale.preferredLanguages ) -> [(String, String)]? { var mappings: [(String, String)] = [] var coveredShared = false for lang in preferredLanguages { let lower = lang.lowercased() let font: String var langRanges: [String] = [] if lower.hasPrefix("ja") { font = "Hiragino Sans" langRanges = japaneseRanges } else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") { font = "PingFang TC" } else if lower.hasPrefix("zh") { font = "PingFang SC" } else { continue } if !coveredShared { for range in sharedCJKRanges { mappings.append((range, font)) } coveredShared = true } for range in langRanges { mappings.append((range, font)) } } return mappings.isEmpty ? nil : mappings } /// Returns only the CJK mappings cmux should auto-inject after respecting /// explicit user overrides and the glyph coverage of the configured /// primary font family. static func autoInjectedCJKFontMappings( preferredLanguages: [String] = Locale.preferredLanguages, configPaths: [String] = loadedCJKScanPaths(), rangeCoverageProbe: ((String, String) -> Bool)? = nil ) -> [(String, String)]? { guard var mappings = cjkFontMappings(preferredLanguages: preferredLanguages) else { return nil } let summary = userFontConfigSummary(configPaths: configPaths) if summary.containsCodepointMap || summary.hasExplicitFontFamilyFallbackChain { return nil } guard let configuredFontFamily = summary.effectiveFontFamilies.first else { return mappings } if let rangeCoverageProbe { mappings.removeAll { range, _ in rangeCoverageProbe(configuredFontFamily, range) } } else if let configuredFont = configuredCTFont(named: configuredFontFamily) { mappings.removeAll { range, _ in fontContainsGlyphs(configuredFont, forRange: range) } } return mappings.isEmpty ? nil : mappings } /// Checks whether the user's Ghostty config files already contain /// a `font-codepoint-map` entry covering CJK ranges. Also checks /// application-support config paths that cmux may load at runtime. static func userConfigContainsCJKCodepointMap( configPaths: [String] = loadedCJKScanPaths() ) -> Bool { userFontConfigSummary(configPaths: configPaths).containsCodepointMap } static func userConfigHasExplicitFontFamilyFallbackChain( configPaths: [String] = loadedCJKScanPaths() ) -> Bool { userFontConfigSummary(configPaths: configPaths).hasExplicitFontFamilyFallbackChain } static func shouldInjectCJKFontFallback( preferredLanguages: [String] = Locale.preferredLanguages, configPaths: [String] = loadedCJKScanPaths(), rangeCoverageProbe: ((String, String) -> Bool)? = nil ) -> Bool { autoInjectedCJKFontMappings( preferredLanguages: preferredLanguages, configPaths: configPaths, rangeCoverageProbe: rangeCoverageProbe ) != nil } private static func configuredCTFont( named name: String, size: CGFloat = 12 ) -> CTFont? { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let font = CTFontCreateWithName(trimmed as CFString, size, nil) let normalizedRequestedName = normalizedFontName(trimmed) let resolvedNames = [ kCTFontFamilyNameKey, kCTFontFullNameKey, kCTFontPostScriptNameKey, ].compactMap { CTFontCopyName(font, $0) as String? } guard resolvedNames.contains(where: { normalizedFontName($0) == normalizedRequestedName }) else { return nil } return font } private static func fontContainsGlyphs( _ font: CTFont, forRange range: String ) -> Bool { guard let characters = cjkCoverageSampleCharactersByRange[range] else { return false } var glyphs = Array(repeating: CGGlyph(), count: characters.count) let hasGlyphs = CTFontGetGlyphsForCharacters(font, characters, &glyphs, characters.count) return hasGlyphs && !glyphs.contains(0) } private static func normalizedFontName(_ name: String) -> String { name .trimmingCharacters(in: .whitespacesAndNewlines) .split(whereSeparator: \.isWhitespace) .joined(separator: " ") .folding(options: [.diacriticInsensitive, .widthInsensitive], locale: Locale(identifier: "en_US_POSIX")) .lowercased() } private static func userFontConfigSummary( configPaths: [String] = loadedCJKScanPaths() ) -> UserFontConfigSummary { var summary = UserFontConfigSummary() var recursiveConfigPaths: [String] = [] for path in configPaths.map({ NSString(string: $0).expandingTildeInPath }) { scanFontConfigFile( atPath: path, summary: &summary, recursiveConfigPaths: &recursiveConfigPaths ) } var loadedRecursivePaths = Set() var index = 0 while index < recursiveConfigPaths.count { let path = recursiveConfigPaths[index] index += 1 let resolved = (path as NSString).standardizingPath guard !loadedRecursivePaths.contains(resolved) else { continue } loadedRecursivePaths.insert(resolved) scanFontConfigFile( atPath: path, summary: &summary, recursiveConfigPaths: &recursiveConfigPaths ) } return summary } /// Returns the top-level config paths that cmux will actually load before /// recursive `config-file` processing. static func loadedCJKScanPaths( currentBundleIdentifier: String? = Bundle.main.bundleIdentifier, appSupportDirectory: URL? = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first ) -> [String] { var paths = [ "~/.config/ghostty/config", "~/.config/ghostty/config.ghostty", ] guard let bundleId = currentBundleIdentifier, !bundleId.isEmpty, let appSupportDirectory else { return paths } let appSupportConfigURLs = cmuxAppSupportConfigURLs( currentBundleIdentifier: bundleId, appSupportDirectory: appSupportDirectory ) paths.append(contentsOf: appSupportConfigURLs.map(\.path)) let releaseDir = appSupportDirectory.appendingPathComponent(releaseBundleIdentifier, isDirectory: true) let releaseLegacyConfig = releaseDir.appendingPathComponent("config", isDirectory: false) let releaseConfig = releaseDir.appendingPathComponent("config.ghostty", isDirectory: false) let releaseConfigSize = configFileSize(at: releaseConfig) let releaseLegacyConfigSize = configFileSize(at: releaseLegacyConfig) if shouldLoadLegacyGhosttyConfig( newConfigFileSize: releaseConfigSize, legacyConfigFileSize: releaseLegacyConfigSize ), !paths.contains(releaseLegacyConfig.path) { paths.append(releaseLegacyConfig.path) } return paths } private static func configFileSize(at url: URL) -> Int? { guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attrs[.size] as? NSNumber else { return nil } return size.intValue } /// Scans a single config file for font settings relevant to cmux's /// injected CJK fallback and updates the pending recursive config-file /// queue using Ghostty's repeatable path semantics. private static func scanFontConfigFile( atPath path: String, summary: inout UserFontConfigSummary, recursiveConfigPaths: inout [String] ) { let resolved = (path as NSString).standardizingPath guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else { return } let parentDir = (resolved as NSString).deletingLastPathComponent for line in contents.components(separatedBy: .newlines) { guard let entry = parsedConfigEntry(from: line) else { continue } switch entry.key { case "font-codepoint-map": guard let value = entry.value else { continue } summary.applyFontCodepointMap(value) case "font-family": guard let value = entry.value else { continue } summary.recordFontFamily(value) case "config-file": guard let value = entry.value else { continue } applyConfigFileDirective( value, parentDir: parentDir, recursiveConfigPaths: &recursiveConfigPaths ) default: continue } } } private static func parsedConfigEntry( from rawLine: String ) -> (key: String, value: String?)? { var trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("\u{FEFF}") { trimmed.removeFirst() } if trimmed.isEmpty || trimmed.hasPrefix("#") { return nil } guard let separatorIndex = trimmed.firstIndex(of: "=") else { return (trimmed.trimmingCharacters(in: .whitespacesAndNewlines), nil) } let key = trimmed[..= 2, value.hasPrefix("\""), value.hasSuffix("\"") { value.removeFirst() value.removeLast() } return (String(key), String(value)) } private static func applyConfigFileDirective( _ value: String, parentDir: String, recursiveConfigPaths: inout [String] ) { if value.isEmpty { recursiveConfigPaths.removeAll() return } var includePath = value if includePath.hasPrefix("?") { includePath.removeFirst() } if includePath.count >= 2, includePath.hasPrefix("\""), includePath.hasSuffix("\"") { includePath.removeFirst() includePath.removeLast() } guard !includePath.isEmpty else { return } let expanded = NSString(string: includePath).expandingTildeInPath let absolute = (expanded as NSString).isAbsolutePath ? expanded : (parentDir as NSString).appendingPathComponent(expanded) recursiveConfigPaths.append(absolute) } static func shouldLoadLegacyGhosttyConfig( newConfigFileSize: Int?, legacyConfigFileSize: Int? ) -> Bool { guard let newConfigFileSize, newConfigFileSize == 0 else { return false } guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false } return true } static func cmuxAppSupportConfigURLs( currentBundleIdentifier: String?, appSupportDirectory: URL, fileManager: FileManager = .default ) -> [URL] { guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { return [] } func existingConfigURLs(for bundleIdentifier: String) -> [URL] { let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true) return [ directory.appendingPathComponent("config", isDirectory: false), directory.appendingPathComponent("config.ghostty", isDirectory: false) ].filter { url in guard let attrs = try? fileManager.attributesOfItem(atPath: url.path), let type = attrs[.type] as? FileAttributeType, type == .typeRegular, let size = attrs[.size] as? NSNumber else { return false } return size.intValue > 0 } } let currentURLs = existingConfigURLs(for: currentBundleIdentifier) if !currentURLs.isEmpty { return currentURLs } if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) { let releaseURLs = existingConfigURLs(for: releaseBundleIdentifier) if !releaseURLs.isEmpty { return releaseURLs } } return [] } static func shouldApplyDefaultBackgroundUpdate( currentScope: GhosttyDefaultBackgroundUpdateScope, incomingScope: GhosttyDefaultBackgroundUpdateScope ) -> Bool { incomingScope.rawValue >= currentScope.rawValue } static func shouldReloadConfigurationForAppearanceChange( previousColorScheme: GhosttyConfig.ColorSchemePreference?, currentColorScheme: GhosttyConfig.ColorSchemePreference ) -> Bool { previousColorScheme != currentColorScheme } static func shouldCaptureScrollLagEvent( samples: Int, averageMs: Double, maxMs: Double, thresholdMs: Double, minimumSamples: Int = 8, minimumAverageMs: Double = 12, nowUptime: TimeInterval, lastReportedUptime: TimeInterval?, cooldown: TimeInterval = 300 ) -> Bool { guard samples >= minimumSamples else { return false } guard averageMs.isFinite, maxMs.isFinite, thresholdMs.isFinite, nowUptime.isFinite, cooldown.isFinite else { return false } guard averageMs >= minimumAverageMs else { return false } guard maxMs > thresholdMs else { return false } if let lastReportedUptime, nowUptime - lastReportedUptime < cooldown { return false } return true } private func loadCmuxAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) let fm = FileManager.default guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } guard let currentBundleIdentifier = Bundle.main.bundleIdentifier, !currentBundleIdentifier.isEmpty else { return } let urls = Self.cmuxAppSupportConfigURLs( currentBundleIdentifier: currentBundleIdentifier, appSupportDirectory: appSupport, fileManager: fm ) guard !urls.isEmpty else { return } for url in urls { url.path.withCString { path in ghostty_config_load_file(config, path) } } #if DEBUG dlog( "loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))" ) #endif #endif } private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real // settings in the legacy `config` file. If the new file exists but is empty, // load the legacy file as a compatibility fallback. let fm = FileManager.default guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) let configNew = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) let configLegacy = ghosttyDir.appendingPathComponent("config", isDirectory: false) func fileSize(_ url: URL) -> Int? { guard let attrs = try? fm.attributesOfItem(atPath: url.path), let size = attrs[.size] as? NSNumber else { return nil } return size.intValue } guard Self.shouldLoadLegacyGhosttyConfig( newConfigFileSize: fileSize(configNew), legacyConfigFileSize: fileSize(configLegacy) ) else { return } configLegacy.path.withCString { path in ghostty_config_load_file(config, path) } #if DEBUG Self.initLog("loaded legacy ghostty config because config.ghostty was empty: \(configLegacy.path)") #endif #endif } /// Schedule a single tick on the main queue, coalescing multiple wakeups. func scheduleTick() { _tickLock.lock() defer { _tickLock.unlock() } guard !_tickScheduled else { return } _tickScheduled = true DispatchQueue.main.async { self.tick() } } func tick() { _tickLock.lock() _tickScheduled = false _tickLock.unlock() guard let app = app else { return } let start = CACurrentMediaTime() ghostty_app_tick(app) let elapsedMs = (CACurrentMediaTime() - start) * 1000 // Track lag during scrolling if isScrolling { scrollLagSampleCount += 1 scrollLagTotalMs += elapsedMs scrollLagMaxMs = max(scrollLagMaxMs, elapsedMs) } } func reloadConfiguration(soft: Bool = false, source: String = "unspecified") { guard let app else { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") return } logThemeAction("reload begin source=\(source) soft=\(soft)") resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))") if soft, let config { ghostty_app_update_config(app, config) lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) scheduleSurfaceRefreshAfterConfigurationReload(source: source) logThemeAction("reload end source=\(source) soft=\(soft) mode=soft") return } guard let newConfig = ghostty_config_new() else { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=config_alloc_failed") return } loadDefaultConfigFilesWithLegacyFallback(newConfig) ghostty_app_update_config(app, newConfig) updateDefaultBackground( from: newConfig, source: "reloadConfiguration(source=\(source))", scope: .unscoped ) DispatchQueue.main.async { self.applyBackgroundToKeyWindow() } if let oldConfig = config { ghostty_config_free(oldConfig) } config = newConfig lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) scheduleSurfaceRefreshAfterConfigurationReload(source: source) logThemeAction("reload end source=\(source) soft=\(soft) mode=full") } private func scheduleSurfaceRefreshAfterConfigurationReload(source: String) { DispatchQueue.main.async { AppDelegate.shared?.refreshTerminalSurfacesAfterGhosttyConfigReload(source: source) } } func synchronizeThemeWithAppearance(_ appearance: NSAppearance?, source: String) { let currentColorScheme = GhosttyConfig.currentColorSchemePreference( appAppearance: appearance ?? NSApp?.effectiveAppearance ) let shouldReload = Self.shouldReloadConfigurationForAppearanceChange( previousColorScheme: lastAppearanceColorScheme, currentColorScheme: currentColorScheme ) if backgroundLogEnabled { let previousLabel: String switch lastAppearanceColorScheme { case .light: previousLabel = "light" case .dark: previousLabel = "dark" case nil: previousLabel = "nil" } let currentLabel: String = currentColorScheme == .dark ? "dark" : "light" logBackground( "appearance sync source=\(source) previous=\(previousLabel) current=\(currentLabel) reload=\(shouldReload)" ) } guard shouldReload else { return } lastAppearanceColorScheme = currentColorScheme reloadConfiguration(source: "appearanceSync:\(source)") } func openConfigurationInTextEdit() { #if os(macOS) let path = ghosttyStringValue(ghostty_config_open_path()) guard !path.isEmpty else { return } let fileURL = URL(fileURLWithPath: path) let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app") let configuration = NSWorkspace.OpenConfiguration() NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) #endif } private func ghosttyStringValue(_ value: ghostty_string_s) -> String { defer { ghostty_string_free(value) } guard let ptr = value.ptr, value.len > 0 else { return "" } let rawPtr = UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self) let buffer = UnsafeBufferPointer(start: rawPtr, count: Int(value.len)) return String(decoding: buffer, as: UTF8.self) } private func resetDefaultBackgroundUpdateScope(source: String) { let previousScope = defaultBackgroundUpdateScope let previousScopeSource = defaultBackgroundScopeSource defaultBackgroundUpdateScope = .unscoped defaultBackgroundScopeSource = "reset:\(source)" if backgroundLogEnabled { logBackground( "default background scope reset source=\(source) previousScope=\(previousScope.logLabel) previousSource=\(previousScopeSource)" ) } } private func updateDefaultBackground( from config: ghostty_config_t?, source: String, scope: GhosttyDefaultBackgroundUpdateScope = .unscoped ) { guard let config else { return } var resolvedColor = defaultBackgroundColor var color = ghostty_config_color_s() let bgKey = "background" if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) { resolvedColor = NSColor( red: CGFloat(color.r) / 255, green: CGFloat(color.g) / 255, blue: CGFloat(color.b) / 255, alpha: 1.0 ) } var opacity = defaultBackgroundOpacity let opacityKey = "background-opacity" _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) opacity = min(1.0, max(0.0, opacity)) applyDefaultBackground( color: resolvedColor, opacity: opacity, source: source, scope: scope ) } func focusFollowsMouseEnabled() -> Bool { guard let config else { return false } var enabled = false let key = "focus-follows-mouse" let keyLength = UInt(key.lengthOfBytes(using: .utf8)) let found = ghostty_config_get(config, &enabled, key, keyLength) return found && enabled } func appleScriptAutomationEnabled() -> Bool { guard let config else { return false } var enabled = false let key = "macos-applescript" _ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8))) return enabled } fileprivate func shellIntegrationMode() -> String { guard let config else { return "detect" } var value: UnsafePointer? let key = "shell-integration" guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), let value else { return "detect" } return String(cString: value) } private func bellFeatures() -> CUnsignedInt { guard let config else { return 0 } var features: CUnsignedInt = 0 let key = "bell-features" _ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8))) return features } private func bellAudioPath() -> String? { guard let config else { return nil } var value = ghostty_config_path_s() let key = "bell-audio-path" guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), let rawPath = value.path else { return nil } let path = String(cString: rawPath) return path.isEmpty ? nil : path } private func bellAudioVolume() -> Float { guard let config else { return 0.5 } var value: Double = 0.5 let key = "bell-audio-volume" _ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) return Float(min(1.0, max(0.0, value))) } private func ringBell() { let features = bellFeatures() if (features & (1 << 0)) != 0 { NSSound.beep() } if (features & (1 << 1)) != 0, let path = bellAudioPath(), let sound = NSSound(contentsOfFile: path, byReference: false) { sound.volume = bellAudioVolume() bellAudioSound = sound if !sound.play() { bellAudioSound = nil } } if (features & (1 << 2)) != 0 { NSApp.requestUserAttention(.informationalRequest) } } private func applyDefaultBackground( color: NSColor, opacity: Double, source: String, scope: GhosttyDefaultBackgroundUpdateScope ) { let previousScope = defaultBackgroundUpdateScope let previousScopeSource = defaultBackgroundScopeSource guard Self.shouldApplyDefaultBackgroundUpdate(currentScope: previousScope, incomingScope: scope) else { if backgroundLogEnabled { logBackground( "default background skipped source=\(source) incomingScope=\(scope.logLabel) currentScope=\(previousScope.logLabel) currentSource=\(previousScopeSource) color=\(color.hexString()) opacity=\(String(format: "%.3f", opacity))" ) } return } defaultBackgroundUpdateScope = scope defaultBackgroundScopeSource = source let previousHex = defaultBackgroundColor.hexString() let previousOpacity = defaultBackgroundOpacity defaultBackgroundColor = color defaultBackgroundOpacity = opacity let hasChanged = previousHex != defaultBackgroundColor.hexString() || abs(previousOpacity - defaultBackgroundOpacity) > 0.0001 if hasChanged { notifyDefaultBackgroundDidChange(source: source) } if backgroundLogEnabled { logBackground( "default background updated source=\(source) scope=\(scope.logLabel) previousScope=\(previousScope.logLabel) previousScopeSource=\(previousScopeSource) previousColor=\(previousHex) previousOpacity=\(String(format: "%.3f", previousOpacity)) color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) changed=\(hasChanged)" ) } } private func nextBackgroundEventId() -> UInt64 { precondition(Thread.isMainThread, "Background event IDs must be generated on main thread") backgroundEventCounter &+= 1 return backgroundEventCounter } private func notifyDefaultBackgroundDidChange(source: String) { let signal = { [self] in let eventId = nextBackgroundEventId() defaultBackgroundNotificationDispatcher.signal( backgroundColor: defaultBackgroundColor, opacity: defaultBackgroundOpacity, eventId: eventId, source: source ) } if Thread.isMainThread { signal() } else { DispatchQueue.main.async(execute: signal) } } private func logThemeAction(_ message: String) { guard backgroundLogEnabled else { return } logBackground("theme action \(message)") } private func actionLabel(for action: ghostty_action_s) -> String { switch action.tag { case GHOSTTY_ACTION_RELOAD_CONFIG: return "reload_config" case GHOSTTY_ACTION_CONFIG_CHANGE: return "config_change" case GHOSTTY_ACTION_COLOR_CHANGE: return "color_change" default: return String(describing: action.tag) } } private func logAction(_ action: ghostty_action_s, target: ghostty_target_s, tabId: UUID?, surfaceId: UUID?) { guard backgroundLogEnabled else { return } let targetLabel = target.tag == GHOSTTY_TARGET_SURFACE ? "surface" : "app" logBackground( "action event target=\(targetLabel) action=\(actionLabel(for: action)) tab=\(tabId?.uuidString ?? "nil") surface=\(surfaceId?.uuidString ?? "nil")" ) } private func performOnMain(_ work: @MainActor () -> T) -> T { if Thread.isMainThread { return MainActor.assumeIsolated { work() } } return DispatchQueue.main.sync { MainActor.assumeIsolated { work() } } } private func splitDirection(from direction: ghostty_action_split_direction_e) -> SplitDirection? { switch direction { case GHOSTTY_SPLIT_DIRECTION_RIGHT: return .right case GHOSTTY_SPLIT_DIRECTION_LEFT: return .left case GHOSTTY_SPLIT_DIRECTION_DOWN: return .down case GHOSTTY_SPLIT_DIRECTION_UP: return .up default: return nil } } private func focusDirection(from direction: ghostty_action_goto_split_e) -> NavigationDirection? { switch direction { // For previous/next, we use left/right as a reasonable default // Bonsplit doesn't have cycle-based navigation case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .left case GHOSTTY_GOTO_SPLIT_NEXT: return .right case GHOSTTY_GOTO_SPLIT_UP: return .up case GHOSTTY_GOTO_SPLIT_DOWN: return .down case GHOSTTY_GOTO_SPLIT_LEFT: return .left case GHOSTTY_GOTO_SPLIT_RIGHT: return .right default: return nil } } private func resizeDirection(from direction: ghostty_action_resize_split_direction_e) -> ResizeDirection? { switch direction { case GHOSTTY_RESIZE_SPLIT_UP: return .up case GHOSTTY_RESIZE_SPLIT_DOWN: return .down case GHOSTTY_RESIZE_SPLIT_LEFT: return .left case GHOSTTY_RESIZE_SPLIT_RIGHT: return .right default: return nil } } private static func callbackContext(from userdata: UnsafeMutableRawPointer?) -> GhosttySurfaceCallbackContext? { guard let userdata else { return nil } return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || action.tag == GHOSTTY_ACTION_COLOR_CHANGE { logAction(action, target: target, tabId: nil, surfaceId: nil) } if action.tag == GHOSTTY_ACTION_DESKTOP_NOTIFICATION { let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager, let tabId = tabManager.selectedTabId else { return false } // Suppress OSC notifications for workspaces with active Claude hook sessions. // The hook system manages notifications with proper lifecycle tracking; // raw OSC notifications would duplicate or outlive the structured hooks. let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager if let workspace = owningManager.tabs.first(where: { $0.id == tabId }), workspace.agentPIDs["claude_code"] != nil { return true } let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, title: command, subtitle: "", body: body ) return true } } if action.tag == GHOSTTY_ACTION_RING_BELL { performOnMain { self.ringBell() } return true } if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG { let soft = action.action.reload_config.soft logThemeAction("reload request target=app soft=\(soft)") performOnMain { GhosttyApp.shared.reloadConfiguration(soft: soft, source: "action.reload_config.app") } return true } if action.tag == GHOSTTY_ACTION_COLOR_CHANGE, action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change let resolvedColor = NSColor( red: CGFloat(change.r) / 255, green: CGFloat(change.g) / 255, blue: CGFloat(change.b) / 255, alpha: 1.0 ) applyDefaultBackground( color: resolvedColor, opacity: defaultBackgroundOpacity, source: "action.color_change.app", scope: .app ) DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } return true } if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE { updateDefaultBackground( from: action.action.config_change.config, source: "action.config_change.app", scope: .app ) DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } return true } return false } let callbackContext = Self.callbackContext(from: ghostty_surface_userdata(target.target.surface)) let callbackTabId = callbackContext?.tabId let callbackSurfaceId = callbackContext?.surfaceId if action.tag == GHOSTTY_ACTION_SHOW_CHILD_EXITED { // The child (shell) exited. Ghostty will fall back to printing // "Process exited. Press any key..." into the terminal unless the host // handles this action. For cmux, the correct behavior is to close // the panel immediately (no prompt). #if DEBUG dlog( "surface.action.showChildExited tab=\(callbackTabId?.uuidString.prefix(5) ?? "nil") " + "surface=\(callbackSurfaceId?.uuidString.prefix(5) ?? "nil")" ) #endif #if DEBUG cmuxWriteChildExitProbe( [ "probeShowChildExitedTabId": callbackTabId?.uuidString ?? "", "probeShowChildExitedSurfaceId": callbackSurfaceId?.uuidString ?? "", ], increments: ["probeShowChildExitedCount": 1] ) #endif // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still // dispatching this action callback. DispatchQueue.main.async { guard let app = AppDelegate.shared else { return } if let callbackTabId, let callbackSurfaceId, let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), workspace.panels[callbackSurfaceId] != nil { manager.closePanelAfterChildExited(tabId: callbackTabId, surfaceId: callbackSurfaceId) } } // Always report handled so Ghostty doesn't print the fallback prompt. return true } guard let surfaceView = callbackContext?.surfaceView else { return false } if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || action.tag == GHOSTTY_ACTION_COLOR_CHANGE { logAction( action, target: target, tabId: callbackTabId ?? surfaceView.tabId, surfaceId: callbackSurfaceId ?? surfaceView.terminalSurface?.id ) } switch action.tag { case GHOSTTY_ACTION_NEW_SPLIT: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, let direction = splitDirection(from: action.action.new_split) else { return false } return performOnMain { guard let app = AppDelegate.shared, let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } return tabManager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } case GHOSTTY_ACTION_RING_BELL: performOnMain { self.ringBell() } return true case GHOSTTY_ACTION_GOTO_SPLIT: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, let direction = focusDirection(from: action.action.goto_split) else { return false } return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.moveSplitFocus(tabId: tabId, surfaceId: surfaceId, direction: direction) } case GHOSTTY_ACTION_RESIZE_SPLIT: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, let direction = resizeDirection(from: action.action.resize_split.direction) else { return false } let amount = action.action.resize_split.amount return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.resizeSplit( tabId: tabId, surfaceId: surfaceId, direction: direction, amount: amount ) } case GHOSTTY_ACTION_EQUALIZE_SPLITS: guard let tabId = surfaceView.tabId else { return false } return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.equalizeSplits(tabId: tabId) } case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id else { return false } return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.toggleSplitZoom(tabId: tabId, surfaceId: surfaceId) } case GHOSTTY_ACTION_SCROLLBAR: let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) surfaceView.enqueueScrollbarUpdate(scrollbar) return true case GHOSTTY_ACTION_CELL_SIZE: let cellSize = CGSize( width: CGFloat(action.action.cell_size.width), height: CGFloat(action.action.cell_size.height) ) DispatchQueue.main.async { surfaceView.cellSize = cellSize NotificationCenter.default.post( name: .ghosttyDidUpdateCellSize, object: surfaceView, userInfo: [GhosttyNotificationKey.cellSize: cellSize] ) } return true case GHOSTTY_ACTION_START_SEARCH: guard let terminalSurface = surfaceView.terminalSurface else { return true } let needle = action.action.start_search.needle.flatMap { String(cString: $0) } DispatchQueue.main.async { if let searchState = terminalSurface.searchState { if let needle, !needle.isEmpty { searchState.needle = needle } } else { terminalSurface.searchState = TerminalSurface.SearchState(needle: needle ?? "") } NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) } return true case GHOSTTY_ACTION_END_SEARCH: guard let terminalSurface = surfaceView.terminalSurface else { return true } DispatchQueue.main.async { terminalSurface.searchState = nil } return true case GHOSTTY_ACTION_SEARCH_TOTAL: guard let terminalSurface = surfaceView.terminalSurface else { return true } let rawTotal = action.action.search_total.total let total: UInt? = rawTotal >= 0 ? UInt(rawTotal) : nil DispatchQueue.main.async { terminalSurface.searchState?.total = total } return true case GHOSTTY_ACTION_SEARCH_SELECTED: guard let terminalSurface = surfaceView.terminalSurface else { return true } let rawSelected = action.action.search_selected.selected let selected: UInt? = rawSelected >= 0 ? UInt(rawSelected) : nil DispatchQueue.main.async { terminalSurface.searchState?.selected = selected } return true case GHOSTTY_ACTION_SET_TITLE: let title = action.action.set_title.title .flatMap { String(cString: $0) } ?? "" if let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id { DispatchQueue.main.async { NotificationCenter.default.post( name: .ghosttyDidSetTitle, object: surfaceView, userInfo: [ GhosttyNotificationKey.tabId: tabId, GhosttyNotificationKey.surfaceId: surfaceId, GhosttyNotificationKey.title: title, ] ) } } return true case GHOSTTY_ACTION_PWD: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id else { return true } let pwd = action.action.pwd.pwd.flatMap { String(cString: $0) } ?? "" DispatchQueue.main.async { AppDelegate.shared?.tabManagerFor(tabId: tabId)?.updateSurfaceDirectory( tabId: tabId, surfaceId: surfaceId, directory: pwd ) } return true case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: guard let tabId = surfaceView.tabId else { return true } let surfaceId = surfaceView.terminalSurface?.id let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" performOnMain { // Suppress OSC notifications for workspaces with active Claude hook sessions. let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }), workspace.agentPIDs["claude_code"] != nil { return } let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, title: command, subtitle: "", body: body ) } return true case GHOSTTY_ACTION_COLOR_CHANGE: if action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change let newColor = NSColor( red: CGFloat(change.r) / 255, green: CGFloat(change.g) / 255, blue: CGFloat(change.b) / 255, alpha: 1.0 ) if backgroundLogEnabled { logBackground( "surface override set tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(newColor.hexString()) default=\(defaultBackgroundColor.hexString()) source=action.color_change.surface" ) } DispatchQueue.main.async { [self] in surfaceView.backgroundColor = newColor surfaceView.applySurfaceBackground() if backgroundLogEnabled { logBackground("OSC background change tab=\(surfaceView.tabId?.uuidString ?? "unknown") color=\(surfaceView.backgroundColor?.description ?? "nil")") } surfaceView.applyWindowBackgroundIfActive() } } return true case GHOSTTY_ACTION_CONFIG_CHANGE: DispatchQueue.main.async { [self] in if let staleOverride = surfaceView.backgroundColor { surfaceView.backgroundColor = nil if backgroundLogEnabled { logBackground( "surface override cleared tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") cleared=\(staleOverride.hexString()) source=action.config_change.surface" ) } surfaceView.applySurfaceBackground() surfaceView.applyWindowBackgroundIfActive() } } updateDefaultBackground( from: action.action.config_change.config, source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", scope: .surface ) if backgroundLogEnabled { logBackground( "surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString())" ) } return true case GHOSTTY_ACTION_RELOAD_CONFIG: let soft = action.action.reload_config.soft logThemeAction( "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" ) return performOnMain { // Keep all runtime theme/default-background state in the same path. GhosttyApp.shared.reloadConfiguration( soft: soft, source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")" ) return true } case GHOSTTY_ACTION_KEY_SEQUENCE: return performOnMain { surfaceView.updateKeySequence(action.action.key_sequence) return true } case GHOSTTY_ACTION_KEY_TABLE: return performOnMain { surfaceView.updateKeyTable(action.action.key_table) return true } case GHOSTTY_ACTION_OPEN_URL: let openUrl = action.action.open_url guard let cstr = openUrl.url else { return false } let urlString = String( data: Data(bytes: cstr, count: Int(openUrl.len)), encoding: .utf8 ) ?? "" #if DEBUG dlog("link.openURL raw=\(urlString)") #endif guard let target = resolveTerminalOpenURLTarget(urlString) else { #if DEBUG dlog("link.openURL resolve failed, returning false") #endif return false } if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { #if DEBUG dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") #endif return performOnMain { NSWorkspace.shared.open(target.url) } } switch target { case let .external(url): #if DEBUG dlog("link.openURL target=external, opening externally url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) } case let .embeddedBrowser(url): if BrowserLinkOpenSettings.shouldOpenExternally(url) { #if DEBUG dlog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) } } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { #if DEBUG dlog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) } } // If a host whitelist is configured and this host isn't in it, open externally. if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) { #if DEBUG dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) } } let sourceWorkspaceId = callbackTabId ?? surfaceView.tabId let sourcePanelId = callbackSurfaceId ?? surfaceView.terminalSurface?.id guard let sourceWorkspaceId, let sourcePanelId else { #if DEBUG dlog("link.openURL target=embedded but tabId/surfaceId=nil") #endif return false } #if DEBUG dlog( "link.openURL target=embedded, opening in browser pane " + "host=\(host) url=\(url) tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)" ) #endif return performOnMain { guard let app = AppDelegate.shared, let resolved = app.workspaceContainingPanel( panelId: sourcePanelId, preferredWorkspaceId: sourceWorkspaceId ) else { #if DEBUG dlog( "link.openURL embedded but workspace lookup failed " + "tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)" ) #endif return false } let workspace = resolved.workspace #if DEBUG if workspace.id != sourceWorkspaceId { dlog( "link.openURL workspace.remap sourceTab=\(sourceWorkspaceId) " + "resolvedTab=\(workspace.id) surfaceId=\(sourcePanelId)" ) } #endif if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: sourcePanelId) { #if DEBUG dlog("link.openURL opening in existing browser pane=\(targetPane)") #endif return workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil } else { #if DEBUG dlog("link.openURL opening as new browser split from surface=\(sourcePanelId)") #endif return workspace.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, url: url) != nil } } } default: return false } } private func applyBackgroundToKeyWindow() { guard let window = activeMainWindow() else { return } if cmuxShouldUseClearWindowBackground(for: defaultBackgroundOpacity) { window.backgroundColor = cmuxTransparentWindowBaseColor() window.isOpaque = false applyWindowBlurIfNeeded(window) if backgroundLogEnabled { logBackground("applied transparent window background opacity=\(String(format: "%.3f", defaultBackgroundOpacity))") } } else { let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity) window.backgroundColor = color window.isOpaque = color.alphaComponent >= 1.0 if backgroundLogEnabled { logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") } } } func applyWindowBlurIfNeeded(_ window: NSWindow) { guard let app = self.app else { return } // ghostty_set_window_background_blur reads background-blur and // background-opacity from the app config internally and calls // CGSSetWindowBackgroundBlurRadius — a compositor-level setter that is // idempotent. It is a no-op when opacity >= 1.0 or blur is disabled, // so we can call it unconditionally whenever the window is transparent. ghostty_set_window_background_blur(app, Unmanaged.passUnretained(window).toOpaque()) } private func activeMainWindow() -> NSWindow? { let keyWindow = NSApp.keyWindow if let raw = keyWindow?.identifier?.rawValue, raw == "cmux.main" || raw.hasPrefix("cmux.main.") { return keyWindow } return NSApp.windows.first(where: { window in guard let raw = window.identifier?.rawValue else { return false } return raw == "cmux.main" || raw.hasPrefix("cmux.main.") }) } func logBackground(_ message: String) { let timestamp = Self.backgroundLogTimestampFormatter.string(from: Date()) let uptimeMs = (ProcessInfo.processInfo.systemUptime - backgroundLogStartUptime) * 1000 let frame60 = Int((CACurrentMediaTime() * 60.0).rounded(.down)) let frame120 = Int((CACurrentMediaTime() * 120.0).rounded(.down)) let threadLabel = Thread.isMainThread ? "main" : "background" backgroundLogLock.lock() defer { backgroundLogLock.unlock() } backgroundLogSequence &+= 1 let sequence = backgroundLogSequence let line = "\(timestamp) seq=\(sequence) t+\(String(format: "%.3f", uptimeMs))ms thread=\(threadLabel) frame60=\(frame60) frame120=\(frame120) cmux bg: \(message)\n" if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false { FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) } if let handle = try? FileHandle(forWritingTo: backgroundLogURL) { defer { try? handle.close() } try? handle.seekToEnd() try? handle.write(contentsOf: data) } } } } // MARK: - Debug Render Instrumentation /// Lightweight instrumentation to detect whether Ghostty is actually requesting Metal drawables. /// This helps catch "frozen until refocus" regressions without relying on screenshots (which can /// mask redraw issues by forcing a window server flush). final class GhosttyMetalLayer: CAMetalLayer { private let lock = NSLock() private var drawableCount: Int = 0 private var lastDrawableTime: CFTimeInterval = 0 func debugStats() -> (count: Int, last: CFTimeInterval) { lock.lock() defer { lock.unlock() } return (drawableCount, lastDrawableTime) } override func nextDrawable() -> CAMetalDrawable? { lock.lock() drawableCount += 1 lastDrawableTime = CACurrentMediaTime() lock.unlock() return super.nextDrawable() } } final class TerminalSurfaceRegistry { static let shared = TerminalSurfaceRegistry() private let lock = NSLock() private let surfaces = NSHashTable.weakObjects() private var runtimeSurfaceOwners: [UInt: UUID] = [:] private init() {} func register(_ surface: TerminalSurface) { lock.lock() defer { lock.unlock() } surfaces.add(surface) } func registerRuntimeSurface(_ surface: ghostty_surface_t, ownerId: UUID) { lock.lock() defer { lock.unlock() } runtimeSurfaceOwners[UInt(bitPattern: surface)] = ownerId } func unregisterRuntimeSurface(_ surface: ghostty_surface_t, ownerId: UUID) { lock.lock() defer { lock.unlock() } let key = UInt(bitPattern: surface) guard runtimeSurfaceOwners[key] == ownerId else { return } runtimeSurfaceOwners.removeValue(forKey: key) } func runtimeSurfaceOwnerId(_ surface: ghostty_surface_t) -> UUID? { lock.lock() defer { lock.unlock() } return runtimeSurfaceOwners[UInt(bitPattern: surface)] } func allSurfaces() -> [TerminalSurface] { lock.lock() let objects = surfaces.allObjects.compactMap { $0 as? TerminalSurface } lock.unlock() return objects.sorted { lhs, rhs in lhs.id.uuidString < rhs.id.uuidString } } } // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) final class TerminalSurface: Identifiable, ObservableObject { final class SearchState: ObservableObject { @Published var needle: String @Published var selected: UInt? @Published var total: UInt? init(needle: String = "") { self.needle = needle self.selected = nil self.total = nil } } private(set) var surface: ghostty_surface_t? private weak var attachedView: GhosttyNSView? /// Whether the runtime Ghostty surface exists and has not begun teardown. /// /// Use this as a quick availability check. Before passing `surface` to /// Ghostty C APIs that dereference the pointer (e.g. /// `ghostty_surface_inherited_config`, `ghostty_surface_quicklook_font`), /// call `liveSurfaceForGhosttyAccess(reason:)` so stale freed pointers are /// rejected and quarantined. var hasLiveSurface: Bool { surface != nil && portalLifecycleState == .live } /// Whether the terminal surface view is currently attached to a window. /// /// Use the hosted view rather than the inner surface view, since the surface can be /// temporarily unattached (surface not yet created / reparenting) even while the panel /// is already in the window. var isViewInWindow: Bool { hostedView.window != nil } let id: UUID private(set) var tabId: UUID /// Port ordinal for CMUX_PORT range assignment var portOrdinal: Int = 0 /// Snapshotted once per app session so all workspaces use consistent values private static let sessionPortBase: Int = { let val = UserDefaults.standard.integer(forKey: "cmuxPortBase") return val > 0 ? val : 9100 }() private static let sessionPortRangeSize: Int = { let val = UserDefaults.standard.integer(forKey: "cmuxPortRange") return val > 0 ? val : 10 }() private let surfaceContext: ghostty_surface_context_e private let configTemplate: CmuxSurfaceConfigTemplate? private let workingDirectory: String? private let initialCommand: String? private let initialEnvironmentOverrides: [String: String] var requestedWorkingDirectory: String? { workingDirectory } private var additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 private var lastPixelHeight: UInt32 = 0 private var lastXScale: CGFloat = 0 private var lastYScale: CGFloat = 0 private let debugMetadataLock = NSLock() private let createdAt: Date = Date() private var runtimeSurfaceCreatedAt: Date? private var teardownRequestedAt: Date? private var teardownRequestReason: String? private var pendingTextQueue: [Data] = [] private var pendingTextBytes: Int = 0 private let maxPendingTextBytes = 1_048_576 private var backgroundSurfaceStartQueued = false private var surfaceCallbackContext: Unmanaged? /// The desired focus state for the Ghostty C surface. May be set before the /// C surface exists (e.g. during layout restoration); `createSurface` syncs /// it on creation. Also used as a dedup guard to avoid redundant /// `ghostty_surface_set_focus` calls (prevents prompt redraws with P10k). /// Initialized to `true` to match Ghostty's default (Terminal.zig focused=true). private var desiredFocusState: Bool = true #if DEBUG private var needsConfirmCloseOverrideForTesting: Bool? private var runtimeSurfaceFreedOutOfBandForTesting = false #endif private enum PortalLifecycleState: String { case live case closing case closed } private struct PortalHostLease { let hostId: ObjectIdentifier let paneId: UUID let instanceSerial: UInt64 let inWindow: Bool let area: CGFloat } private var portalLifecycleState: PortalLifecycleState = .live private var portalLifecycleGeneration: UInt64 = 1 private var activePortalHostLease: PortalHostLease? @Published var searchState: SearchState? = nil { didSet { if let searchState { hostedView.cancelFocusRequest() #if DEBUG dlog("find.searchState created tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") #endif searchNeedleCancellable = searchState.$needle .removeDuplicates() .map { needle -> AnyPublisher in if needle.isEmpty || needle.count >= 3 { return Just(needle).eraseToAnyPublisher() } return Just(needle) .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) .eraseToAnyPublisher() } .switchToLatest() .sink { [weak self] needle in #if DEBUG dlog("find.needle updated tab=\(self?.tabId.uuidString.prefix(5) ?? "?") surface=\(self?.id.uuidString.prefix(5) ?? "?") chars=\(needle.count)") #endif _ = self?.performBindingAction("search:\(needle)") } } else if oldValue != nil { searchNeedleCancellable = nil #if DEBUG dlog("find.searchState cleared tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") #endif _ = performBindingAction("end_search") } } } @Published private(set) var keyboardCopyModeActive: Bool = false private var searchNeedleCancellable: AnyCancellable? var currentKeyStateIndicatorText: String? { surfaceView.currentKeyStateIndicatorText } init( tabId: UUID, context: ghostty_surface_context_e, configTemplate: CmuxSurfaceConfigTemplate?, workingDirectory: String? = nil, initialCommand: String? = nil, initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { self.id = UUID() self.tabId = tabId self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides) self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: additionalEnvironment) // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer // has non-zero bounds and the renderer can initialize without presenting a blank/stretched // intermediate frame on the first real resize. let view = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) self.surfaceView = view self.hostedView = GhosttySurfaceScrollView(surfaceView: view) // Surface is created when attached to a view hostedView.attachSurface(self) TerminalSurfaceRegistry.shared.register(self) } func updateWorkspaceId(_ newTabId: UUID) { tabId = newTabId attachedView?.tabId = newTabId surfaceView.tabId = newTabId } private static func mergedNormalizedEnvironment( base: [String: String], overrides: [String: String] ) -> [String: String] { var merged: [String: String] = [:] merged.reserveCapacity(base.count + overrides.count) for (rawKey, value) in base { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } merged[key] = value } for (rawKey, value) in overrides { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } merged[key] = value } return merged } static func mergedStartupEnvironment( base: [String: String], protectedKeys: Set, additionalEnvironment: [String: String], initialEnvironmentOverrides: [String: String] ) -> [String: String] { var merged = base for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty && !protectedKeys.contains(key) { merged[key] = value } for (key, value) in initialEnvironmentOverrides where !protectedKeys.contains(key) { merged[key] = value } return merged } func isAttached(to view: GhosttyNSView) -> Bool { attachedView === view && surface != nil } func portalBindingGeneration() -> UInt64 { portalLifecycleGeneration } func portalBindingStateLabel() -> String { portalLifecycleState.rawValue } private func withDebugMetadataLock(_ body: () -> T) -> T { debugMetadataLock.lock() defer { debugMetadataLock.unlock() } return body() } func debugCreatedAt() -> Date { withDebugMetadataLock { createdAt } } func debugRuntimeSurfaceCreatedAt() -> Date? { withDebugMetadataLock { runtimeSurfaceCreatedAt } } func debugTeardownRequest() -> (requestedAt: Date?, reason: String?) { withDebugMetadataLock { (teardownRequestedAt, teardownRequestReason) } } func debugLastKnownWorkspaceId() -> UUID { tabId } func debugSurfaceContextLabel() -> String { cmuxSurfaceContextName(surfaceContext) } func debugInitialCommand() -> String? { initialCommand } func debugPortalHostLease() -> (hostId: String?, paneId: UUID?, inWindow: Bool?, area: CGFloat?) { guard let activePortalHostLease else { return (nil, nil, nil, nil) } return ( hostId: String(describing: activePortalHostLease.hostId), paneId: activePortalHostLease.paneId, inWindow: activePortalHostLease.inWindow, area: activePortalHostLease.area ) } func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { guard portalLifecycleState == .live else { return false } if let expectedSurfaceId, expectedSurfaceId != id { return false } if let expectedGeneration, expectedGeneration != portalLifecycleGeneration { return false } return true } @MainActor func liveSurfaceForGhosttyAccess(reason: String) -> ghostty_surface_t? { guard hasLiveSurface, let surface else { return nil } let registry = TerminalSurfaceRegistry.shared let registeredOwnerId = registry.runtimeSurfaceOwnerId(surface) guard registeredOwnerId == id, cmuxSurfacePointerAppearsLive(surface) else { let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil registry.unregisterRuntimeSurface(surface, ownerId: id) self.surface = nil activePortalHostLease = nil recordTeardownRequest(reason: reason) markPortalLifecycleClosed(reason: reason) #if DEBUG let registeredOwnerToken = registeredOwnerId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( "surface.lifecycle.stale surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + "registryOwner=\(registeredOwnerToken)" ) #endif callbackContext?.release() return nil } return surface } private static let portalHostAreaThreshold: CGFloat = 4 private static func portalHostArea(for bounds: CGRect) -> CGFloat { max(0, bounds.width) * max(0, bounds.height) } private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool { lease.inWindow && lease.area > portalHostAreaThreshold } @discardableResult func preparePortalHostReplacementIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool { guard let current = activePortalHostLease, current.hostId == hostId else { return false } // SwiftUI can tear down and rebuild the host NSView during split churn. Keep the // existing portal binding alive, but make the old lease non-usable so the next // distinct host in the same pane can claim immediately instead of waiting for a // later layout-follow-up retry. activePortalHostLease = PortalHostLease( hostId: current.hostId, paneId: current.paneId, instanceSerial: current.instanceSerial, inWindow: false, area: current.area ) #if DEBUG dlog( "terminal.portal.host.rearm surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " + "area=\(String(format: "%.1f", current.area))" ) #endif return true } func claimPortalHost( hostId: ObjectIdentifier, paneId: PaneID, instanceSerial: UInt64, inWindow: Bool, bounds: CGRect, reason: String ) -> Bool { let next = PortalHostLease( hostId: hostId, paneId: paneId.id, instanceSerial: instanceSerial, inWindow: inWindow, area: Self.portalHostArea(for: bounds) ) if let current = activePortalHostLease { if current.hostId == hostId { activePortalHostLease = next return true } let currentUsable = Self.portalHostIsUsable(current) let nextUsable = Self.portalHostIsUsable(next) // During split churn SwiftUI can briefly keep the old host alive while the new // host for the same pane is already in the window. Prefer the newer live host // immediately so the surface moves with the pane instead of waiting for a later // update from unrelated focus/layout work. let newerSamePaneHostReady = current.paneId == paneId.id && nextUsable && next.instanceSerial > current.instanceSerial // A dragged terminal must hand off immediately when it moves to a different pane. // Waiting for the old host to become "worse" leaves the moved pane blank/stale. let shouldReplace = current.paneId != paneId.id || !currentUsable || newerSamePaneHostReady if shouldReplace { #if DEBUG dlog( "terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) " + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " + "replacingInWin=\(current.inWindow ? 1 : 0) " + "replacingArea=\(String(format: "%.1f", current.area))" ) #endif activePortalHostLease = next return true } #if DEBUG dlog( "terminal.portal.host.skip surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) " + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " + "ownerInWin=\(current.inWindow ? 1 : 0) " + "ownerArea=\(String(format: "%.1f", current.area))" ) #endif return false } activePortalHostLease = next #if DEBUG dlog( "terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) " + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) replacingHost=nil" ) #endif return true } func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) { guard let current = activePortalHostLease, current.hostId == hostId else { return } activePortalHostLease = nil #if DEBUG dlog( "terminal.portal.host.release surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " + "inWin=\(current.inWindow ? 1 : 0) " + "area=\(String(format: "%.1f", current.area))" ) #endif } private func recordTeardownRequest(reason: String) { withDebugMetadataLock { if teardownRequestedAt == nil { teardownRequestedAt = Date() } if let existing = teardownRequestReason, !existing.isEmpty { return } teardownRequestReason = reason } } private func recordRuntimeSurfaceCreation() { withDebugMetadataLock { runtimeSurfaceCreatedAt = Date() } } private func allowsRuntimeSurfaceCreation() -> Bool { portalLifecycleState == .live } func beginPortalCloseLifecycle(reason: String) { guard portalLifecycleState != .closed else { return } guard portalLifecycleState != .closing else { return } recordTeardownRequest(reason: reason) portalLifecycleState = .closing portalLifecycleGeneration &+= 1 #if DEBUG dlog( "surface.lifecycle.close.begin surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + "generation=\(portalLifecycleGeneration)" ) #endif } private func markPortalLifecycleClosed(reason: String) { guard portalLifecycleState != .closed else { return } portalLifecycleState = .closed portalLifecycleGeneration &+= 1 #if DEBUG dlog( "surface.lifecycle.close.sealed surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + "generation=\(portalLifecycleGeneration)" ) #endif } /// Explicitly free the Ghostty runtime surface. Idempotent — safe to call /// before deinit; deinit will skip the free if already torn down. @MainActor func teardownSurface() { recordTeardownRequest(reason: "surface.teardown") markPortalLifecycleClosed(reason: "teardown") let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil let surfaceToFree = surface if let surfaceToFree { TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id) } surface = nil guard let surfaceToFree else { callbackContext?.release() return } #if DEBUG if runtimeSurfaceFreedOutOfBandForTesting { runtimeSurfaceFreedOutOfBandForTesting = false callbackContext?.release() return } #endif Task { @MainActor in // Keep free behavior aligned with deinit: perform the runtime teardown on // the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant. ghostty_surface_free(surfaceToFree) callbackContext?.release() } } #if DEBUG private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log" private static let sizeLogPath = "/tmp/cmux-ghostty-size.log" func debugCurrentPixelSize() -> (width: UInt32, height: UInt32) { (lastPixelWidth, lastPixelHeight) } func debugDesiredFocusState() -> Bool { desiredFocusState } private static func surfaceLog(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: surfaceLogPath) { handle.seekToEndOfFile() handle.write(Data(line.utf8)) handle.closeFile() } else { FileManager.default.createFile(atPath: surfaceLogPath, contents: line.data(using: .utf8)) } } private static func sizeLog(_ message: String) { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1" else { return } let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: sizeLogPath) { handle.seekToEndOfFile() handle.write(Data(line.utf8)) handle.closeFile() } else { FileManager.default.createFile(atPath: sizeLogPath, contents: line.data(using: .utf8)) } } #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, view.window?.backingScaleFactor ?? view.layer?.contentsScale ?? NSScreen.main?.backingScaleFactor ?? 1.0 ) return (scale, scale, scale) } private func scaleApproximatelyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool { abs(lhs - rhs) <= epsilon } func attachToView(_ view: GhosttyNSView) { #if DEBUG dlog( "surface.attach surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque()) " + "attached=\(attachedView != nil ? 1 : 0) hasSurface=\(surface != nil ? 1 : 0) inWindow=\(view.window != nil ? 1 : 0)" ) #endif // If already attached to this view, nothing to do. // Still re-assert the display id: during split close tree restructuring, the view can be // removed/re-added (or briefly have window/screen nil) without recreating the surface. // Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing // or stale, the surface can appear visually frozen until a focus/visibility change. // SwiftUI also re-enters this path for ordinary state propagation (drag hover, active // markers, visibility flags), so avoid forcing a geometry refresh when the attachment // itself is unchanged. if attachedView === view && surface != nil { #if DEBUG dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") #endif if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0, let s = surface { ghostty_surface_set_display_id(s, displayID) } return } if let attachedView, attachedView !== view { #if DEBUG dlog( "surface.attach.skip surface=\(id.uuidString.prefix(5)) reason=alreadyAttachedToDifferentView " + "current=\(Unmanaged.passUnretained(attachedView).toOpaque()) new=\(Unmanaged.passUnretained(view).toOpaque())" ) #endif return } attachedView = view // If surface doesn't exist yet, create it once the view is in a real window so // content scale and pixel geometry are derived from the actual backing context. if surface == nil { guard allowsRuntimeSurfaceCreation() else { #if DEBUG dlog( "surface.attach.skip surface=\(id.uuidString.prefix(5)) " + "reason=lifecycle.\(portalLifecycleState.rawValue)" ) #endif return } guard view.window != nil else { #if DEBUG dlog( "surface.attach.defer surface=\(id.uuidString.prefix(5)) reason=noWindow " + "bounds=\(String(format: "%.1fx%.1f", view.bounds.width, view.bounds.height))" ) #endif return } #if DEBUG dlog("surface.attach.create surface=\(id.uuidString.prefix(5))") #endif createSurface(for: view) #if DEBUG dlog("surface.attach.create.done surface=\(id.uuidString.prefix(5)) hasSurface=\(surface != nil ? 1 : 0)") #endif } else if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0, let s = surface { // Surface exists but we're (re)attaching after a view hierarchy move; ensure display id. ghostty_surface_set_display_id(s, displayID) #if DEBUG dlog("surface.attach.displayId surface=\(id.uuidString.prefix(5)) display=\(displayID)") #endif } } private func createSurface(for view: GhosttyNSView) { guard allowsRuntimeSurfaceCreation() else { #if DEBUG dlog( "surface.create.skip surface=\(id.uuidString.prefix(5)) " + "reason=lifecycle.\(portalLifecycleState.rawValue)" ) Self.surfaceLog( "createSurface SKIPPED surface=\(id.uuidString) tab=\(tabId.uuidString) lifecycle=\(portalLifecycleState.rawValue)" ) #endif return } #if DEBUG let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } ?? "(unset)" let terminfo = getenv("TERMINFO").flatMap { String(cString: $0) } ?? "(unset)" let xdg = getenv("XDG_DATA_DIRS").flatMap { String(cString: $0) } ?? "(unset)" let manpath = getenv("MANPATH").flatMap { String(cString: $0) } ?? "(unset)" Self.surfaceLog("createSurface start surface=\(id.uuidString) tab=\(tabId.uuidString) bounds=\(view.bounds) inWindow=\(view.window != nil) resources=\(resourcesDir) terminfo=\(terminfo) xdg=\(xdg) manpath=\(manpath)") #endif guard let app = GhosttyApp.shared.app else { print("Ghostty app not initialized") #if DEBUG Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty app not initialized") #endif return } let scaleFactors = scaleFactors(for: view) let baseConfig = configTemplate ?? CmuxSurfaceConfigTemplate() var surfaceConfig = ghostty_surface_config_new() surfaceConfig.font_size = baseConfig.fontSize surfaceConfig.wait_after_command = baseConfig.waitAfterCommand surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceView: view, terminalSurface: self)) surfaceConfig.userdata = callbackContext.toOpaque() surfaceCallbackContext?.release() 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, UnsafeMutablePointer)] = [] defer { for (key, value) in envStorage { free(key) free(value) } } var env = baseConfig.environmentVariables var protectedStartupEnvironmentKeys: Set = [] func setManagedEnvironmentValue(_ key: String, _ value: String) { env[key] = value protectedStartupEnvironmentKeys.insert(key) } setManagedEnvironmentValue("CMUX_SURFACE_ID", id.uuidString) setManagedEnvironmentValue("CMUX_WORKSPACE_ID", tabId.uuidString) // Backward-compatible shell integration keys used by existing scripts/tests. setManagedEnvironmentValue("CMUX_PANEL_ID", id.uuidString) setManagedEnvironmentValue("CMUX_TAB_ID", tabId.uuidString) let socketPath = SocketControlSettings.socketPath() setManagedEnvironmentValue("CMUX_SOCKET_PATH", socketPath) // Backward-compatible alias expected by older scripts and third-party integrations. setManagedEnvironmentValue("CMUX_SOCKET", socketPath) if let bundledCLIURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux"), FileManager.default.isExecutableFile(atPath: bundledCLIURL.path) { setManagedEnvironmentValue("CMUX_BUNDLED_CLI_PATH", bundledCLIURL.path) } if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { setManagedEnvironmentValue("CMUX_BUNDLE_ID", bundleId) } // Port range for this workspace (base/range snapshotted once per app session) do { let startPort = Self.sessionPortBase + portOrdinal * Self.sessionPortRangeSize setManagedEnvironmentValue("CMUX_PORT", String(startPort)) setManagedEnvironmentValue("CMUX_PORT_END", String(startPort + Self.sessionPortRangeSize - 1)) setManagedEnvironmentValue("CMUX_PORT_RANGE", String(Self.sessionPortRangeSize)) } let claudeHooksEnabled = ClaudeCodeIntegrationSettings.hooksEnabled() if !claudeHooksEnabled { setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1") } if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { let currentPath = env["PATH"] ?? getenv("PATH").map { String(cString: $0) } ?? ProcessInfo.processInfo.environment["PATH"] ?? "" if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) { let separator = currentPath.isEmpty ? "" : ":" setManagedEnvironmentValue("PATH", "\(cliBinPath)\(separator)\(currentPath)") } } // Shell integration: inject ZDOTDIR wrapper for zsh shells. let shellIntegrationEnabled = UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true if shellIntegrationEnabled, let integrationDir = Bundle.main.resourceURL?.appendingPathComponent("shell-integration").path { setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION", "1") setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION_DIR", integrationDir) let shell = (env["SHELL"]?.isEmpty == false ? env["SHELL"] : nil) ?? getenv("SHELL").map { String(cString: $0) } ?? ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" let shellName = URL(fileURLWithPath: shell).lastPathComponent if shellName == "zsh" { if GhosttyApp.shared.shellIntegrationMode() != "none" { setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION", "1") } let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil) ?? getenv("ZDOTDIR").map { String(cString: $0) } ?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil) if let candidateZdotdir, !candidateZdotdir.isEmpty { var isGhosttyInjected = false let ghosttyResources = (env["GHOSTTY_RESOURCES_DIR"]?.isEmpty == false ? env["GHOSTTY_RESOURCES_DIR"] : nil) ?? getenv("GHOSTTY_RESOURCES_DIR").map { String(cString: $0) } ?? (ProcessInfo.processInfo.environment["GHOSTTY_RESOURCES_DIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["GHOSTTY_RESOURCES_DIR"] : nil) if let ghosttyResources { let ghosttyZdotdir = URL(fileURLWithPath: ghosttyResources) .appendingPathComponent("shell-integration/zsh").path isGhosttyInjected = (candidateZdotdir == ghosttyZdotdir) } if !isGhosttyInjected { setManagedEnvironmentValue("CMUX_ZSH_ZDOTDIR", candidateZdotdir) } } setManagedEnvironmentValue("ZDOTDIR", integrationDir) } else if shellName == "bash" { if GhosttyApp.shared.shellIntegrationMode() != "none" { setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_BASH_INTEGRATION", "1") } // macOS ships /bin/bash 3.2, where Ghostty's automatic bash // integration is unsupported and HOME-based wrapper startup is // not reliable. Bootstrap cmux bash integration on the first // interactive prompt instead. setManagedEnvironmentValue("PROMPT_COMMAND", """ unset PROMPT_COMMAND; \ if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \ _cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \ [[ -r "$_cmux_ghostty_bash" ]] && source "$_cmux_ghostty_bash"; \ fi; \ if [[ "${CMUX_SHELL_INTEGRATION:-1}" != "0" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then \ _cmux_bash_integration="$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; \ [[ -r "$_cmux_bash_integration" ]] && source "$_cmux_bash_integration"; \ fi; \ unset _cmux_ghostty_bash _cmux_bash_integration; \ if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi """) } } env = Self.mergedStartupEnvironment( base: env, protectedKeys: protectedStartupEnvironmentKeys, additionalEnvironment: additionalEnvironment, initialEnvironmentOverrides: initialEnvironmentOverrides ) if !env.isEmpty { envVars.reserveCapacity(env.count) envStorage.reserveCapacity(env.count) for (key, value) in env { guard let keyPtr = strdup(key), let valuePtr = strdup(value) else { continue } envStorage.append((keyPtr, valuePtr)) envVars.append(ghostty_env_var_s(key: keyPtr, value: valuePtr)) } } let createSurface = { [self] in if !envVars.isEmpty { let envVarsCount = envVars.count envVars.withUnsafeMutableBufferPointer { buffer in surfaceConfig.env_vars = buffer.baseAddress surfaceConfig.env_var_count = envVarsCount self.surface = ghostty_surface_new(app, &surfaceConfig) } } else { self.surface = ghostty_surface_new(app, &surfaceConfig) } } let resolvedWorkingDirectory: String? = { if let workingDirectory, !workingDirectory.isEmpty { return workingDirectory } return baseConfig.workingDirectory }() let resolvedCommand: String? = { if let initialCommand, !initialCommand.isEmpty { return initialCommand } return baseConfig.command }() let resolvedInitialInput = baseConfig.initialInput func withOptionalCString(_ value: String?, _ body: (UnsafePointer?) -> T) -> T { guard let value else { return body(nil) } return value.withCString(body) } let createWithCommandAndWorkingDirectory = { withOptionalCString(resolvedCommand) { cCommand in surfaceConfig.command = cCommand withOptionalCString(resolvedWorkingDirectory) { cWorkingDir in surfaceConfig.working_directory = cWorkingDir withOptionalCString(resolvedInitialInput) { cInitialInput in surfaceConfig.initial_input = cInitialInput createSurface() } } } } createWithCommandAndWorkingDirectory() if surface == nil { surfaceCallbackContext?.release() surfaceCallbackContext = nil print("Failed to create ghostty surface") #if DEBUG Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty_surface_new returned nil") if let cfg = GhosttyApp.shared.config { let count = Int(ghostty_config_diagnostics_count(cfg)) Self.surfaceLog("createSurface diagnostics count=\(count)") for i in 0.. 0, hpx > 0 { 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?.fontSize, 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) } } // Sync the desired focus state to the newly created C surface. Ghostty // surfaces default to focused=true, but this surface may have been // logically unfocused before the C surface existed (e.g. during layout // restoration). Always sync unconditionally so we don't couple to // Ghostty's default. ghostty_surface_set_focus(createdSurface, desiredFocusState) NotificationCenter.default.post( name: .terminalSurfaceDidBecomeReady, object: self, userInfo: [ "surfaceId": id, "workspaceId": tabId ] ) flushPendingTextIfNeeded() // Kick an initial draw after creation/size setup. On some startup paths Ghostty can // miss the first vsync callback and sit on a blank frame until another focus/visibility // transition nudges the renderer. view.forceRefreshSurface() ghostty_surface_refresh(createdSurface) #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 } @discardableResult func updateSize( width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat, backingSize: CGSize? = nil ) -> Bool { guard let surface = surface else { return false } _ = layerScale 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 false } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight #if DEBUG Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)") #endif guard scaleChanged || sizeChanged else { return false } #if DEBUG if sizeChanged { let win = attachedView?.window != nil ? "1" : "0" Self.sizeLog("updateSize surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) win=\(win)") } #endif if scaleChanged { ghostty_surface_set_content_scale(surface, xScale, yScale) lastXScale = xScale lastYScale = yScale } if sizeChanged { ghostty_surface_set_size(surface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx } // Let Ghostty continue rendering on its own wakeups for steady-state frames. return true } /// Force a full size recalculation and surface redraw. func forceRefresh(reason: String = "unspecified") { let hasSurface = surface != nil let viewState: String if let view = attachedView { let inWindow = view.window != nil let bounds = view.bounds let metalOK = (view.layer as? CAMetalLayer) != nil viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)" } else { viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" } #if DEBUG dlog("forceRefresh: \(id) reason=\(reason) \(viewState)") #endif guard let view = attachedView, let surface, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { return } guard let currentSurface = self.surface else { return } // Re-read self.surface before each ghostty call to guard against the surface // being freed during wake-from-sleep geometry reconciliation (issue #432). // The surface can be invalidated between calls when AppKit layout triggers // view lifecycle changes (e.g., forceRefreshSurface → layout → deinit → free). // Reassert display id on topology churn (split close/reparent) before forcing a refresh. // This avoids a first-run stuck-vsync state where Ghostty believes vsync is active // but callbacks have not resumed for the current display. if let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, displayID != 0 { ghostty_surface_set_display_id(currentSurface, displayID) } view.forceRefreshSurface() guard let surface = self.surface else { return } ghostty_surface_refresh(surface) } func applyWindowBackgroundIfActive() { surfaceView.applyWindowBackgroundIfActive() } /// Keep `desiredFocusState` in sync when the hosted view's responder chain /// calls `ghostty_surface_set_focus` directly (bypassing `setFocus`). /// Without this, `createSurface` would replay a stale state on recreation. func recordExternalFocusState(_ focused: Bool) { desiredFocusState = focused } func setFocus(_ focused: Bool) { // Only send focus events when the state changes to avoid redundant // prompt redraws with zsh themes like Powerlevel10k. guard focused != desiredFocusState else { return } desiredFocusState = focused // Track desired state even before the C surface exists (e.g. during // layout restoration). createSurface syncs the state once created. guard let surface = surface else { return } ghostty_surface_set_focus(surface, focused) // If we focus a surface while it is being rapidly reparented (closing splits, etc), // Ghostty's CVDisplayLink can end up started before the display id is valid, leaving // hasVsync() true but with no callbacks ("stuck-vsync-no-frames"). Reasserting the // display id *after* focusing lets Ghostty restart the display link when needed. if focused { if let view = attachedView, let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, displayID != 0 { ghostty_surface_set_display_id(surface, displayID) } } } func setOcclusion(_ visible: Bool) { guard let surface = surface else { return } ghostty_surface_set_occlusion(surface, visible) } func needsConfirmClose() -> Bool { #if DEBUG if let needsConfirmCloseOverrideForTesting { return needsConfirmCloseOverrideForTesting } #endif guard let surface = surface else { return false } return ghostty_surface_needs_confirm_quit(surface) } func sendText(_ text: String) { guard let data = text.data(using: .utf8), !data.isEmpty else { return } guard let surface = surface else { enqueuePendingText(data) return } writeTextData(data, to: surface) } /// Send text with control characters (Return, Tab, etc.) delivered as key /// events so the shell processes them, while regular text is sent via the /// normal key-text path. Mirrors `TerminalController.sendSocketText`. func sendInput(_ text: String) { guard let surface = surface else { return } var bufferedText = "" var previousWasCR = false for scalar in text.unicodeScalars { switch scalar.value { case 0x0A: // \n — skip if preceded by \r (already sent Return) if !previousWasCR { flushText(&bufferedText, surface: surface) sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return } previousWasCR = false case 0x0D: flushText(&bufferedText, surface: surface) sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return previousWasCR = true case 0x09: flushText(&bufferedText, surface: surface) sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab previousWasCR = false case 0x1B: flushText(&bufferedText, surface: surface) sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape previousWasCR = false default: bufferedText.unicodeScalars.append(scalar) previousWasCR = false } } flushText(&bufferedText, surface: surface) } private func flushText(_ buffer: inout String, surface: ghostty_surface_t) { guard !buffer.isEmpty else { return } var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = 0 keyEvent.mods = GHOSTTY_MODS_NONE keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.unshifted_codepoint = 0 keyEvent.composing = false buffer.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } buffer.removeAll(keepingCapacity: true) } private func sendKeyEvent(surface: ghostty_surface_t, keycode: UInt32) { var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = keycode keyEvent.mods = GHOSTTY_MODS_NONE keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.unshifted_codepoint = 0 keyEvent.composing = false keyEvent.text = nil _ = ghostty_surface_key(surface, keyEvent) } func requestBackgroundSurfaceStartIfNeeded() { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.requestBackgroundSurfaceStartIfNeeded() } return } guard allowsRuntimeSurfaceCreation() else { return } guard surface == nil, attachedView != nil else { return } guard !backgroundSurfaceStartQueued else { return } backgroundSurfaceStartQueued = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.backgroundSurfaceStartQueued = false guard self.allowsRuntimeSurfaceCreation() else { return } guard self.surface == nil, let view = self.attachedView else { return } #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime #endif self.createSurface(for: view) #if DEBUG let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 dlog( "surface.background_start surface=\(self.id.uuidString.prefix(8)) inWindow=\(view.window != nil ? 1 : 0) ready=\(self.surface != nil ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs))" ) #endif } } private func writeTextData(_ data: Data, to surface: ghostty_surface_t) { data.withUnsafeBytes { rawBuffer in guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } ghostty_surface_text(surface, baseAddress, UInt(rawBuffer.count)) } } private func enqueuePendingText(_ data: Data) { let incomingBytes = data.count while !pendingTextQueue.isEmpty && pendingTextBytes + incomingBytes > maxPendingTextBytes { let dropped = pendingTextQueue.removeFirst() pendingTextBytes -= dropped.count } pendingTextQueue.append(data) pendingTextBytes += incomingBytes #if DEBUG dlog( "surface.send_text.queue surface=\(id.uuidString.prefix(8)) chunks=\(pendingTextQueue.count) bytes=\(pendingTextBytes)" ) #endif } private func flushPendingTextIfNeeded() { guard let surface = surface, !pendingTextQueue.isEmpty else { return } let queued = pendingTextQueue let queuedBytes = pendingTextBytes pendingTextQueue.removeAll(keepingCapacity: false) pendingTextBytes = 0 for chunk in queued { writeTextData(chunk, to: surface) } #if DEBUG dlog( "surface.send_text.flush surface=\(id.uuidString.prefix(8)) chunks=\(queued.count) bytes=\(queuedBytes)" ) #endif } func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } return action.withCString { cString in ghostty_surface_binding_action(surface, cString, UInt(strlen(cString))) } } @discardableResult func toggleKeyboardCopyMode() -> Bool { let handled = surfaceView.toggleKeyboardCopyMode() if handled { setKeyboardCopyModeActive(surfaceView.isKeyboardCopyModeActive) } return handled } func setKeyboardCopyModeActive(_ active: Bool) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setKeyboardCopyModeActive(active) } return } if keyboardCopyModeActive != active { keyboardCopyModeActive = active } hostedView.syncKeyStateIndicator(text: surfaceView.currentKeyStateIndicatorText) } func hasSelection() -> Bool { guard let surface = surface else { return false } return ghostty_surface_has_selection(surface) } #if DEBUG @MainActor func setNeedsConfirmCloseOverrideForTesting(_ value: Bool?) { needsConfirmCloseOverrideForTesting = value } /// Test-only helper to deterministically simulate a released runtime surface. @MainActor func releaseSurfaceForTesting() { let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil guard let surfaceToFree = surface else { callbackContext?.release() return } TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id) surface = nil ghostty_surface_free(surfaceToFree) callbackContext?.release() } /// Test-only helper to simulate a stale Swift wrapper whose native surface /// was already freed out-of-band. @MainActor func replaceSurfaceWithFreedPointerForTesting() { guard !runtimeSurfaceFreedOutOfBandForTesting else { return } let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil guard let surfaceToFree = surface else { callbackContext?.release() return } TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id) ghostty_surface_free(surfaceToFree) runtimeSurfaceFreedOutOfBandForTesting = true callbackContext?.release() } #endif deinit { markPortalLifecycleClosed(reason: "deinit") let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil // Nil out the surface pointer so any in-flight closures (e.g. geometry // reconcile dispatched via DispatchQueue.main.async) that read self.surface // before this object is fully deallocated will see nil and bail out, // rather than passing a freed pointer to ghostty_surface_refresh (#432). let surfaceToFree = surface if let surfaceToFree { TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id) } surface = nil guard let surfaceToFree else { #if DEBUG dlog( "surface.lifecycle.deinit.skip surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=noRuntimeSurface" ) #endif callbackContext?.release() return } #if DEBUG if runtimeSurfaceFreedOutOfBandForTesting { runtimeSurfaceFreedOutOfBandForTesting = false callbackContext?.release() return } #endif #if DEBUG let surfaceToken = String(id.uuidString.prefix(5)) let workspaceToken = String(tabId.uuidString.prefix(5)) dlog( "surface.lifecycle.deinit.begin surface=\(surfaceToken) " + "workspace=\(workspaceToken) hasAttachedView=\(attachedView != nil ? 1 : 0) " + "hostedInWindow=\(hostedView.window != nil ? 1 : 0)" ) #endif // Keep teardown asynchronous to avoid re-entrant close/deinit loops, but retain // callback userdata until surface free completes so callbacks never dereference // a deallocated view pointer. Task { @MainActor in ghostty_surface_free(surfaceToFree) callbackContext?.release() #if DEBUG dlog( "surface.lifecycle.deinit.end surface=\(surfaceToken) " + "workspace=\(workspaceToken) freed=1" ) #endif } } } extension TerminalSurface { @MainActor func owningWorkspace() -> Workspace? { AppDelegate.shared?.workspaceFor(tabId: tabId) } } // MARK: - Ghostty Surface View class GhosttyNSView: NSView, NSUserInterfaceValidations { private static let focusDebugEnabled: Bool = { if ProcessInfo.processInfo.environment["CMUX_FOCUS_DEBUG"] == "1" { return true } return UserDefaults.standard.bool(forKey: "cmuxFocusDebug") }() internal enum DropPlan: Equatable { case insertText(String) case uploadFiles([URL]) case reject } private static let dropTypes: Set = [ .string, .fileURL, .URL, .png, .tiff, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.heic.identifier), NSPasteboard.PasteboardType(UTType.heif.identifier) ] private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder") fileprivate static func focusLog(_ message: String) { guard focusDebugEnabled else { return } FocusLogStore.shared.append(message) NSLog("[FOCUSDBG] %@", message) } weak var terminalSurface: TerminalSurface? var scrollbar: GhosttyScrollbar? /// Pending scrollbar value written from the action callback thread; /// read and cleared on the main thread by `flushPendingScrollbar()`. /// Access is guarded by `_scrollbarLock` because the action callback /// fires on Ghostty's I/O thread while the flush runs on main. private var _pendingScrollbar: GhosttyScrollbar? private var _scrollbarFlushScheduled = false private let _scrollbarLock = NSLock() var cellSize: CGSize = .zero /// Coalesce high-frequency scrollbar updates into a single main-thread /// dispatch. The action callback (which may fire thousands of times per /// second during bulk output like `seq 1 100000`) stores the latest value /// and schedules exactly one async flush. func enqueueScrollbarUpdate(_ newValue: GhosttyScrollbar) { _scrollbarLock.lock() defer { _scrollbarLock.unlock() } // Store the latest value (always overwrites — only the newest matters). _pendingScrollbar = newValue let needsSchedule = !_scrollbarFlushScheduled if needsSchedule { _scrollbarFlushScheduled = true } // If a flush is already scheduled, skip the dispatch — the scheduled // block will pick up the latest value. guard needsSchedule else { return } DispatchQueue.main.async { [weak self] in self?.flushPendingScrollbar() } } private func flushPendingScrollbar() { _scrollbarLock.lock() _scrollbarFlushScheduled = false let pending = _pendingScrollbar _pendingScrollbar = nil _scrollbarLock.unlock() guard let pending else { return } scrollbar = pending NotificationCenter.default.post( name: .ghosttyDidUpdateScrollbar, object: self, userInfo: [GhosttyNotificationKey.scrollbar: pending] ) } var desiredFocus: Bool = false var suppressingReparentFocus: Bool = false var tabId: UUID? var onFocus: (() -> Void)? var onTriggerFlash: (() -> Void)? var backgroundColor: NSColor? private var appliedColorScheme: ghostty_color_scheme_e? private var lastLoggedSurfaceBackgroundSignature: String? private var lastLoggedWindowBackgroundSignature: String? private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] fileprivate private(set) var keyboardCopyModeActive = false private var wordPathHoverActive = false private var keyboardCopyModeConsumedKeyUps: Set = [] private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState() private var keyboardCopyModeViewportRow: Int? /// Tracks whether the user has explicitly entered visual selection mode (v). /// Separate from Ghostty's `has_selection` because copy mode always maintains /// a 1-cell selection as a visible cursor. This flag determines whether /// movements should extend the selection (visual) or scroll the viewport. private var keyboardCopyModeVisualActive = false fileprivate var isKeyboardCopyModeActive: Bool { keyboardCopyModeActive } fileprivate var currentKeyStateIndicatorText: String? { if let name = keyTables.last { return terminalKeyTableIndicatorText(name) } if keyboardCopyModeActive { return terminalKeyboardCopyModeIndicatorText } return nil } #if DEBUG private static let keyLatencyProbeEnabled: Bool = { if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" { return true } return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") }() static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)? #endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? private var lastScrollEventTime: CFTimeInterval = 0 private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? private var deferredSurfaceSizeRetryQueued = false private var lastDrawableSize: CGSize = .zero private var isFindEscapeSuppressionArmed = false #if DEBUG private var lastSizeSkipSignature: String? #endif private var hasUsableFocusGeometry: Bool { bounds.width > 1 && bounds.height > 1 } static func shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: Bool, pressedMouseButtons: Int, appIsActive: Bool, windowIsKey: Bool, alreadyFirstResponder: Bool, visibleInUI: Bool, hasUsableGeometry: Bool, hiddenInHierarchy: Bool ) -> Bool { guard focusFollowsMouseEnabled else { return false } guard pressedMouseButtons == 0 else { return false } guard appIsActive, windowIsKey else { return false } guard !alreadyFirstResponder else { return false } guard visibleInUI, hasUsableGeometry, !hiddenInHierarchy else { return false } return true } // Visibility is used for focus gating. Explicit portal visibility transitions // also drive Ghostty occlusion so hidden workspace/split surfaces pause and // queue a redraw when they become visible again. fileprivate var isVisibleInUI: Bool { visibleInUI } fileprivate func setVisibleInUI(_ visible: Bool) { visibleInUI = visible } override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } override func makeBackingLayer() -> CALayer { let metalLayer = CAMetalLayer() metalLayer.pixelFormat = .bgra8Unorm metalLayer.isOpaque = false // framebufferOnly=false lets the macOS compositor read the drawable // when blending translucent or blurred window layers. This matches // standalone Ghostty's SurfaceView and is required for background-opacity // and background-blur to render correctly. metalLayer.framebufferOnly = false return metalLayer } 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)) } private func effectiveBackgroundColor() -> NSColor { let base = backgroundColor ?? GhosttyApp.shared.defaultBackgroundColor let opacity = GhosttyApp.shared.defaultBackgroundOpacity return base.withAlphaComponent(opacity) } func applySurfaceBackground() { let color = effectiveBackgroundColor() if let layer { CATransaction.begin() CATransaction.setDisableActions(true) // GhosttySurfaceScrollView owns the panel background fill. Keeping this layer clear // avoids stacking multiple identical translucent backgrounds (which looks opaque). layer.backgroundColor = NSColor.clear.cgColor layer.isOpaque = false CATransaction.commit() } terminalSurface?.hostedView.setBackgroundColor(color) if GhosttyApp.shared.backgroundLogEnabled { let signature = "\(color.hexString()):\(String(format: "%.3f", color.alphaComponent))" if signature != lastLoggedSurfaceBackgroundSignature { lastLoggedSurfaceBackgroundSignature = signature let hasOverride = backgroundColor != nil let overrideHex = backgroundColor?.hexString() ?? "nil" let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() let source = hasOverride ? "surfaceOverride" : "defaultBackground" GhosttyApp.shared.logBackground( "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" ) } } } // Theme/background application is window-local. During cross-window workspace // switches (e.g. jump-to-unread), the global active tab manager can lag behind. // Prefer the owning window's selected workspace when available. static func shouldApplyWindowBackground( surfaceTabId: UUID?, owningManagerExists: Bool, owningSelectedTabId: UUID?, activeSelectedTabId: UUID? ) -> Bool { guard let surfaceTabId else { return true } if owningManagerExists { guard let owningSelectedTabId else { return true } return owningSelectedTabId == surfaceTabId } if let activeSelectedTabId { return activeSelectedTabId == surfaceTabId } return true } func applyWindowBackgroundIfActive() { guard let window else { return } let appDelegate = AppDelegate.shared let owningManager = tabId.flatMap { appDelegate?.tabManagerFor(tabId: $0) } let owningSelectedTabId = owningManager?.selectedTabId let activeSelectedTabId = owningManager == nil ? appDelegate?.tabManager?.selectedTabId : nil guard Self.shouldApplyWindowBackground( surfaceTabId: tabId, owningManagerExists: owningManager != nil, owningSelectedTabId: owningSelectedTabId, activeSelectedTabId: activeSelectedTabId ) else { return } applySurfaceBackground() let color = effectiveBackgroundColor() if cmuxShouldUseClearWindowBackground(for: color.alphaComponent) { window.backgroundColor = cmuxTransparentWindowBaseColor() window.isOpaque = false GhosttyApp.shared.applyWindowBlurIfNeeded(window) } else { window.backgroundColor = color window.isOpaque = color.alphaComponent >= 1.0 } if GhosttyApp.shared.backgroundLogEnabled { let signature = "\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent) ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))" if signature != lastLoggedWindowBackgroundSignature { lastLoggedWindowBackgroundSignature = signature let hasOverride = backgroundColor != nil let overrideHex = backgroundColor?.hexString() ?? "nil" let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() let source = hasOverride ? "surfaceOverride" : "defaultBackground" GhosttyApp.shared.logBackground( "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) transparent=\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent)) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" ) } } } private func installEventMonitor() { guard eventMonitor == nil else { return } eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in return self?.localEventHandler(event) ?? event } } private func localEventHandler(_ event: NSEvent) -> NSEvent? { switch event.type { case .scrollWheel: return localEventScrollWheel(event) default: return event } } private func localEventScrollWheel(_ event: NSEvent) -> NSEvent? { guard let window, let eventWindow = event.window, window == eventWindow else { return event } let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } Self.focusLog("localEventScrollWheel: window=\(ObjectIdentifier(window)) firstResponder=\(String(describing: window.firstResponder))") return event } func attachSurface(_ surface: TerminalSurface) { let isSameSurface = terminalSurface === surface let isAlreadyAttached = surface.isAttached(to: self) if !isSameSurface { appliedColorScheme = nil } terminalSurface = surface tabId = surface.tabId if !isAlreadyAttached { surface.attachToView(self) } surface.setKeyboardCopyModeActive(keyboardCopyModeActive) if !isAlreadyAttached { updateSurfaceSize() } applySurfaceBackground() applySurfaceColorScheme(force: !isSameSurface || !isAlreadyAttached) } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) self.windowObserver = nil } // Balance the cursor stack if the view is removed while hover is active if wordPathHoverActive { wordPathHoverActive = false NSCursor.pop() } #if DEBUG dlog( "surface.view.windowMove surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "pending=\(String(format: "%.1fx%.1f", pendingSurfaceSize?.width ?? 0, pendingSurfaceSize?.height ?? 0))" ) #endif guard let window else { return } // If the surface creation was deferred while detached, create/attach it now. terminalSurface?.attachToView(self) if let terminalSurface { NotificationCenter.default.post( name: .terminalSurfaceHostedViewDidMoveToWindow, object: terminalSurface, userInfo: [ "surfaceId": terminalSurface.id, "workspaceId": terminalSurface.tabId ] ) } windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didChangeScreenNotification, object: window, queue: .main ) { [weak self] notification in self?.windowDidChangeScreen(notification) } if let surface = terminalSurface?.surface, let displayID = window.screen?.displayID, displayID != 0 { ghostty_surface_set_display_id(surface, displayID) } // 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() updateSurfaceSize() applySurfaceBackground() applySurfaceColorScheme(force: true) GhosttyApp.shared.synchronizeThemeWithAppearance( effectiveAppearance, source: "surface.viewDidMoveToWindow" ) applyWindowBackgroundIfActive() invalidateTextInputCoordinates() } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() if GhosttyApp.shared.backgroundLogEnabled { let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) GhosttyApp.shared.logBackground( "surface appearance changed tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil")" ) } applySurfaceColorScheme() GhosttyApp.shared.synchronizeThemeWithAppearance( effectiveAppearance, source: "surface.viewDidChangeEffectiveAppearance" ) } fileprivate func updateOcclusionState() { // Intentionally no-op: we don't drive libghostty occlusion from AppKit occlusion state. // This avoids transient clears during reparenting and keeps rendering logic minimal. } override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() if let window { CATransaction.begin() CATransaction.setDisableActions(true) layer?.contentsScale = window.backingScaleFactor CATransaction.commit() } updateSurfaceSize() invalidateTextInputCoordinates() } override func layout() { super.layout() updateSurfaceSize() invalidateTextInputCoordinates() } 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 static func hasTabDragPasteboardTypes() -> Bool { let types = NSPasteboard(name: .drag).types ?? [] return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType) } private static func isDragResizeEvent(_ eventType: NSEvent.EventType?) -> Bool { switch eventType { case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true default: return false } } private static func shouldDeferSurfaceResizeForActiveDrag() -> Bool { // The drag pasteboard can retain tab-transfer UTIs briefly after a split command // or other layout churn. Only defer terminal resizes while an actual drag event // is in flight; otherwise pre-existing panes can stay stuck at their old size. guard hasTabDragPasteboardTypes() else { return false } return isDragResizeEvent(NSApp.currentEvent?.type) } private func activeSurfaceResizeDeferralReason() -> String? { return Self.shouldDeferSurfaceResizeForActiveDrag() ? "tabDrag" : nil } private func scheduleDeferredSurfaceSizeRetryIfNeeded() { guard window != nil else { return } guard !deferredSurfaceSizeRetryQueued else { return } deferredSurfaceSizeRetryQueued = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.deferredSurfaceSizeRetryQueued = false _ = self.updateSurfaceSize() } } @discardableResult private func updateSurfaceSize(size: CGSize? = nil) -> Bool { guard let terminalSurface = terminalSurface else { return false } 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))" if lastSizeSkipSignature != signature { dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=nonPositive size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "inWindow=\(window != nil ? 1 : 0)" ) lastSizeSkipSignature = signature } #endif return false } pendingSurfaceSize = size if let deferralReason = activeSurfaceResizeDeferralReason() { scheduleDeferredSurfaceSizeRetryIfNeeded() #if DEBUG let signature = "\(deferralReason)-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))" if lastSizeSkipSignature != signature { dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(deferralReason) " + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "inWindow=\(window != nil ? 1 : 0)" ) lastSizeSkipSignature = signature } #endif return false } guard let window else { #if DEBUG let signature = "noWindow-\(Int(size.width))x\(Int(size.height))" if lastSizeSkipSignature != signature { dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=noWindow " + "size=\(String(format: "%.1fx%.1f", size.width, size.height))" ) lastSizeSkipSignature = signature } #endif return false } // First principles: derive pixel size from AppKit's backing conversion for the current // window/screen. Avoid updating Ghostty while detached from a window. let backingSize = convertToBacking(NSRect(origin: .zero, size: size)).size guard backingSize.width > 0, backingSize.height > 0 else { #if DEBUG let signature = "zeroBacking-\(Int(backingSize.width))x\(Int(backingSize.height))" if lastSizeSkipSignature != signature { dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=zeroBacking " + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "backing=\(String(format: "%.1fx%.1f", backingSize.width, backingSize.height))" ) lastSizeSkipSignature = signature } #endif return false } #if DEBUG if lastSizeSkipSignature != nil { dlog( "surface.size.resume surface=\(terminalSurface.id.uuidString.prefix(5)) " + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "backing=\(String(format: "%.1fx%.1f", backingSize.width, backingSize.height))" ) lastSizeSkipSignature = nil } #endif 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)) ) var didChange = false CATransaction.begin() CATransaction.setDisableActions(true) if let layer, !nearlyEqual(layer.contentsScale, layerScale) { didChange = true } layer?.contentsScale = layerScale layer?.masksToBounds = true if let metalLayer = layer as? CAMetalLayer { if drawablePixelSize != lastDrawableSize || metalLayer.drawableSize != drawablePixelSize { if metalLayer.drawableSize != drawablePixelSize { didChange = true } if metalLayer.drawableSize != drawablePixelSize { metalLayer.drawableSize = drawablePixelSize } lastDrawableSize = drawablePixelSize } } CATransaction.commit() let surfaceSizeChanged = terminalSurface.updateSize( width: size.width, height: size.height, xScale: xScale, yScale: yScale, layerScale: layerScale, backingSize: backingSize ) return didChange || surfaceSizeChanged } @discardableResult fileprivate func pushTargetSurfaceSize(_ size: CGSize) -> Bool { updateSurfaceSize(size: size) } #if DEBUG fileprivate func debugPendingSurfaceSize() -> CGSize? { pendingSurfaceSize } #endif /// Force a full size reconciliation for the current bounds. /// Keep the drawable-size cache intact so redundant refresh paths do not /// reallocate Metal drawables when the pixel size is unchanged. @discardableResult func forceRefreshSurface() -> Bool { updateSurfaceSize() } private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool { abs(lhs - rhs) <= epsilon } func expectedPixelSize(for pointsSize: CGSize) -> CGSize { let backing = convertToBacking(NSRect(origin: .zero, size: pointsSize)).size if backing.width > 0, backing.height > 0 { return backing } let scale = max(1.0, window?.backingScaleFactor ?? layer?.contentsScale ?? 1.0) return CGSize(width: pointsSize.width * scale, height: pointsSize.height * scale) } // Convenience accessor for the ghostty surface private var surface: ghostty_surface_t? { terminalSurface?.surface } private func applySurfaceColorScheme(force: Bool = false) { guard let surface else { return } let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) let scheme: ghostty_color_scheme_e = bestMatch == .darkAqua ? GHOSTTY_COLOR_SCHEME_DARK : GHOSTTY_COLOR_SCHEME_LIGHT if !force, appliedColorScheme == scheme { if GhosttyApp.shared.backgroundLogEnabled { let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" GhosttyApp.shared.logBackground( "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=false" ) } return } ghostty_surface_set_color_scheme(surface, scheme) appliedColorScheme = scheme if GhosttyApp.shared.backgroundLogEnabled { let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" GhosttyApp.shared.logBackground( "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=true" ) } } @discardableResult private func ensureSurfaceReadyForInput() -> ghostty_surface_t? { if let surface = surface { return surface } guard window != nil else { return nil } terminalSurface?.attachToView(self) updateSurfaceSize(size: bounds.size) applySurfaceColorScheme(force: true) return surface } private func requestInputRecoveryAfterSurfaceMiss(reason: String) { terminalSurface?.requestBackgroundSurfaceStartIfNeeded() #if DEBUG dlog( "focus.input_recovery surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=\(reason) inWindow=\(window != nil ? 1 : 0)" ) #endif } func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } return action.withCString { cString in ghostty_surface_binding_action(surface, cString, UInt(strlen(cString))) } } @discardableResult func toggleKeyboardCopyMode() -> Bool { guard surface != nil else { return false } setKeyboardCopyModeActive(!keyboardCopyModeActive) if !keyboardCopyModeActive, let surface { _ = ghostty_surface_clear_selection(surface) } return true } private func setKeyboardCopyModeActive(_ active: Bool) { keyboardCopyModeInputState.reset() keyboardCopyModeVisualActive = false keyboardCopyModeActive = active if active, let surface { keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row _ = ghostty_surface_clear_selection(surface) if keyboardCopyModeViewportRow == nil { keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface) } // Create a 1-cell selection at the terminal cursor to serve as a // visible cursor indicator in copy mode. _ = ghostty_surface_select_cursor_cell(surface) } else { keyboardCopyModeViewportRow = nil } terminalSurface?.setKeyboardCopyModeActive(active) } private func performBindingAction(_ action: String, repeatCount: Int) { let count = terminalKeyboardCopyModeClampCount(repeatCount) for _ in 0 ..< count { _ = performBindingAction(action) } } private func currentKeyboardCopyModeViewportRow(surface: ghostty_surface_t) -> Int { let rows = max(Int(ghostty_surface_size(surface).rows), 1) let fallback = rows - 1 return max(0, min(rows - 1, keyboardCopyModeViewportRow ?? fallback)) } private func keyboardCopyModeImeViewportRow(surface: ghostty_surface_t) -> Int { let rows = max(Int(ghostty_surface_size(surface).rows), 1) var x: Double = 0 var y: Double = 0 var width: Double = 0 var height: Double = 0 ghostty_surface_ime_point(surface, &x, &y, &width, &height) return terminalKeyboardCopyModeInitialViewportRow( rows: rows, imePointY: y, imeCellHeight: height ) } private func keyboardCopyModeSelectionAnchor(surface: ghostty_surface_t) -> (row: Int, y: Double)? { let size = ghostty_surface_size(surface) guard size.rows > 0, size.columns > 0 else { return nil } guard ghostty_surface_select_cursor_cell(surface) else { return nil } var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } let rows = max(Int(size.rows), 1) let cols = max(Int(size.columns), 1) let rawRow = Int(text.offset_start) / cols let clampedRow = max(0, min(rows - 1, rawRow)) return (row: clampedRow, y: text.tl_px_y) } private func refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: ghostty_surface_t) { // In visual mode the user owns the selection range; don't disturb it. // Outside visual mode we keep a 1-cell cursor selection for visibility, // so we still need to refresh the viewport row after scrolling. guard !keyboardCopyModeVisualActive else { return } guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return } keyboardCopyModeViewportRow = anchor.row // Preserve the visible cursor indicator. _ = ghostty_surface_select_cursor_cell(surface) } private func copyCurrentViewportLinesToClipboard( surface: ghostty_surface_t, startRow: Int, lineCount: Int ) -> Bool { let clampedCount = terminalKeyboardCopyModeClampCount(lineCount) let rows = max(Int(ghostty_surface_size(surface).rows), 1) let targetRow = max(0, min(rows - 1, startRow)) let endRow = min(rows - 1, targetRow + clampedCount - 1) guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return false } _ = ghostty_surface_clear_selection(surface) var imeX: Double = 0 var imeY: Double = 0 var imeWidth: Double = 0 var imeHeight: Double = 0 ghostty_surface_ime_point(surface, &imeX, &imeY, &imeWidth, &imeHeight) let cellHeight = imeHeight > 0 ? imeHeight : max(bounds.height / Double(rows), 1) let yMax = max(bounds.height - 1, 0) let startRawY = anchor.y + (Double(targetRow - anchor.row) * cellHeight) let endRawY = anchor.y + (Double(endRow - anchor.row) * cellHeight) let startY = max(0, min(startRawY, yMax)) let endY = max(0, min(endRawY, yMax)) let xMax = max(bounds.width - 1, 0) let startX = min(1, xMax) let endX = xMax let mods = ghostty_input_mods_e(rawValue: GHOSTTY_MODS_NONE.rawValue) ?? GHOSTTY_MODS_NONE ghostty_surface_mouse_pos(surface, startX, startY, mods) guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else { return false } defer { _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) } ghostty_surface_mouse_pos(surface, endX, endY, mods) guard ghostty_surface_has_selection(surface) else { return false } return performBindingAction("copy_to_clipboard") } private func handleKeyboardCopyModeIfNeeded(_ event: NSEvent, surface: ghostty_surface_t) -> Bool { guard keyboardCopyModeActive else { return false } if terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: event.modifierFlags) { keyboardCopyModeInputState.reset() return false } // Use the visual-mode flag instead of raw has_selection so that the // 1-cell cursor selection doesn't make every motion behave as visual. let hasSelection = keyboardCopyModeVisualActive let resolution = terminalKeyboardCopyModeResolve( keyCode: event.keyCode, charactersIgnoringModifiers: event.charactersIgnoringModifiers, modifierFlags: event.modifierFlags, hasSelection: hasSelection, state: &keyboardCopyModeInputState ) guard case let .perform(action, count) = resolution else { return true } switch action { case .exit: _ = ghostty_surface_clear_selection(surface) setKeyboardCopyModeActive(false) case .startSelection: keyboardCopyModeVisualActive = true case .clearSelection: keyboardCopyModeVisualActive = false _ = ghostty_surface_clear_selection(surface) // Re-create 1-cell cursor at terminal cursor position. _ = ghostty_surface_select_cursor_cell(surface) case .copyAndExit: _ = performBindingAction("copy_to_clipboard") _ = ghostty_surface_clear_selection(surface) setKeyboardCopyModeActive(false) case .copyLineAndExit: let startRow = currentKeyboardCopyModeViewportRow(surface: surface) _ = copyCurrentViewportLinesToClipboard( surface: surface, startRow: startRow, lineCount: count ) _ = ghostty_surface_clear_selection(surface) setKeyboardCopyModeActive(false) case let .scrollLines(delta): _ = performBindingAction("scroll_page_lines:\(delta * count)") refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .scrollPage(delta): performBindingAction(delta > 0 ? "scroll_page_down" : "scroll_page_up", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .scrollHalfPage(delta): let fraction = delta > 0 ? 0.5 : -0.5 performBindingAction("scroll_page_fractional:\(fraction)", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .scrollToTop: keyboardCopyModeViewportRow = 0 _ = performBindingAction("scroll_to_top") case .scrollToBottom: keyboardCopyModeViewportRow = max(Int(ghostty_surface_size(surface).rows) - 1, 0) _ = performBindingAction("scroll_to_bottom") case let .jumpToPrompt(delta): _ = performBindingAction("jump_to_prompt:\(delta * count)") refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .startSearch: _ = performBindingAction("start_search") case .searchNext: performBindingAction("navigate_search:next", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .searchPrevious: performBindingAction("navigate_search:previous", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .adjustSelection(direction): performBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count) } return true } // MARK: - Input Handling @IBAction func copy(_ sender: Any?) { _ = performBindingAction("copy_to_clipboard") } // MARK: - Clipboard paste @IBAction func paste(_ sender: Any?) { _ = performBindingAction("paste_from_clipboard") } /// Pastes clipboard text as plain text, stripping any rich formatting. @IBAction func pasteAsPlainText(_ sender: Any?) { _ = performBindingAction("paste_from_clipboard") } /// Validates whether edit menu items (copy, paste, split) should be enabled. func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { switch item.action { case #selector(copy(_:)): guard let surface = surface else { return false } return ghostty_surface_has_selection(surface) case #selector(paste(_:)): return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) case #selector(pasteAsPlainText(_:)): return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)): return canSplitCurrentSurface() default: return true } } // MARK: - Accessibility /// Expose the terminal surface as an editable accessibility element. /// Voice input tools frequently target AX text areas for text insertion. override func isAccessibilityElement() -> Bool { true } override func accessibilityRole() -> NSAccessibility.Role? { .textArea } override func accessibilityHelp() -> String? { "Terminal content area" } override func accessibilityValue() -> Any? { // We don't keep a full terminal text snapshot in this layer. // Expose selected text when available; otherwise provide an empty value // so AX clients still treat this as an editable text area. accessibilitySelectedText() ?? "" } override func setAccessibilityValue(_ value: Any?) { let content: String switch value { case let v as NSAttributedString: content = v.string case let v as String: content = v default: return } guard !content.isEmpty else { return } #if DEBUG dlog("ime.ax.setValue len=\(content.count)") #endif let inject = { self.insertText(content, replacementRange: NSRange(location: NSNotFound, length: 0)) } if Thread.isMainThread { inject() } else { DispatchQueue.main.async(execute: inject) } } override func accessibilitySelectedTextRange() -> NSRange { selectedRange() } override func accessibilitySelectedText() -> String? { guard let snapshot = readSelectionSnapshot() else { return nil } return snapshot.string.isEmpty ? nil : snapshot.string } private func readSelectionSnapshot() -> SelectionSnapshot? { guard let surface else { return nil } var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } let selected: String if let ptr = text.text, text.text_len > 0 { let selectedData = Data(bytes: ptr, count: Int(text.text_len)) selected = String(decoding: selectedData, as: UTF8.self) } else { selected = "" } return SelectionSnapshot( range: NSRange(location: Int(text.offset_start), length: Int(text.offset_len)), string: selected, topLeft: CGPoint(x: text.tl_px_x, y: text.tl_px_y) ) } private func visibleDocumentRectInScreenCoordinates() -> NSRect { let localRect = visibleRect let windowRect = convert(localRect, to: nil) guard let window else { return windowRect } return window.convertToScreen(windowRect) } private func invalidateTextInputCoordinates(selectionChanged: Bool = false) { guard let inputContext else { return } inputContext.invalidateCharacterCoordinates() guard selectionChanged else { return } // `textInputClientDidUpdateSelection` is absent from the Xcode 16.2 AppKit SDK // used by the macOS 14 compatibility lane, so call it dynamically when present. let updateSelectionSelector = NSSelectorFromString("textInputClientDidUpdateSelection") guard inputContext.responds(to: updateSelectionSelector) else { return } _ = inputContext.perform(updateSelectionSelector) } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { PaneFirstClickFocusSettings.isEnabled() } override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() var shouldApplySurfaceFocus = false if result { // If we become first responder before the ghostty surface exists (e.g. during // split/tab creation while the surface is still being created), record the desired focus. desiredFocus = true // During programmatic splits, SwiftUI reparents the old NSView which triggers // becomeFirstResponder. Suppress onFocus + ghostty_surface_set_focus to prevent // the old view from stealing focus and creating model/surface divergence. if suppressingReparentFocus { #if DEBUG dlog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif return result } // Always notify the host app that this pane became the first responder so bonsplit // focus/selection can converge. Previously this was gated on `surface != nil`, which // allowed a mismatch where AppKit focus moved but the UI focus indicator (bonsplit) // stayed behind. let hiddenInHierarchy = isHiddenOrHasHiddenAncestor if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy { shouldApplySurfaceFocus = true onFocus?() } else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) { #if DEBUG dlog( "focus.firstResponder SUPPRESSED (hidden_or_tiny) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) hidden=\(hiddenInHierarchy ? 1 : 0)" ) #endif } } if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") #if DEBUG dlog("focus.firstResponder surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") if let terminalSurface { AppDelegate.shared?.recordJumpUnreadFocusIfExpected( tabId: terminalSurface.tabId, surfaceId: terminalSurface.id ) } #endif if let terminalSurface { NotificationCenter.default.post( name: .ghosttyDidBecomeFirstResponderSurface, object: nil, userInfo: [ GhosttyNotificationKey.tabId: terminalSurface.tabId, GhosttyNotificationKey.surfaceId: terminalSurface.id, ] ) } terminalSurface?.recordExternalFocusState(true) ghostty_surface_set_focus(surface, true) // Ghostty only restarts its vsync display link on display-id changes while focused. // During rapid split close / SwiftUI reparenting, the view can reattach to a window // and get its display id set *before* it becomes first responder; in that case, the // renderer can remain stuck until some later screen/focus transition. Reassert the // display id now that we're focused to ensure the renderer is running. if let displayID = window?.screen?.displayID, displayID != 0 { ghostty_surface_set_display_id(surface, displayID) } } return result } override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() if result { desiredFocus = false terminalSurface?.recordExternalFocusState(false) } if result, let surface = surface { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("resignFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") ghostty_surface_set_focus(surface, false) } return result } // For NSTextInputClient - accumulates text during key events private var keyTextAccumulator: [String]? = nil private var markedText = NSMutableAttributedString() private var lastPerformKeyEvent: TimeInterval? private struct SelectionSnapshot { let range: NSRange let string: String let topLeft: CGPoint } #if DEBUG // Test-only accessors for keyTextAccumulator to verify CJK IME composition behavior. func setKeyTextAccumulatorForTesting(_ value: [String]?) { keyTextAccumulator = value } var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator } func shouldSuppressShiftSpaceFallbackTextForTesting(event: NSEvent, markedTextBefore: Bool) -> Bool { shouldSuppressShiftSpaceFallbackText(event: event, markedTextBefore: markedTextBefore) } // Test-only IME point override so firstRect behavior can be regression tested. private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { imePointOverrideForTesting = (x, y, width, height) } func clearIMEPointForTesting() { imePointOverrideForTesting = nil } #endif #if DEBUG private func recordKeyLatency(path: String, event: NSEvent) { guard Self.keyLatencyProbeEnabled else { return } CmuxTypingTiming.logEventDelay(path: path, event: event) } #endif // Prevents NSBeep for unimplemented actions from interpretKeyEvents override func doCommand(by selector: Selector) { // Intentionally empty - prevents system beep on unhandled key commands } /// Some third-party voice input apps inject committed text by sending the /// responder-chain `insertText:` action (single-argument form). /// Route that into our NSTextInputClient path so text lands in the terminal. override func insertText(_ insertString: Any) { insertText(insertString, replacementRange: NSRange(location: NSNotFound, length: 0)) } override func performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() defer { CmuxTypingTiming.logDuration( path: "terminal.performKeyEquivalent", startedAt: typingTimingStart, event: event ) } #endif guard event.type == .keyDown else { return false } guard let fr = window?.firstResponder as? NSView, fr === self || fr.isDescendant(of: self) else { return false } guard let surface = ensureSurfaceReadyForInput() else { return false } // If the IME is composing (marked text present) and the key has no Cmd // modifier, don't intercept — let it flow through to keyDown so the input // method can process it normally. Cmd-based shortcuts should still work // during composition since Cmd is never part of IME input sequences. if hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { return false } #if DEBUG recordKeyLatency(path: "performKeyEquivalent", event: event) #endif #if DEBUG cmuxWriteChildExitProbe( [ "probePerformCharsHex": cmuxScalarHex(event.characters), "probePerformCharsIgnoringHex": cmuxScalarHex(event.charactersIgnoringModifiers), "probePerformKeyCode": String(event.keyCode), "probePerformModsRaw": String(event.modifierFlags.rawValue), "probePerformSurfaceId": terminalSurface?.id.uuidString ?? "", ], increments: ["probePerformKeyEquivalentCount": 1] ) #endif // Check if this event matches a Ghostty keybinding. let bindingFlags: ghostty_binding_flags_e? = { var keyEvent = ghosttyKeyEvent(for: event, surface: surface) let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? "" var flags = ghostty_binding_flags_e(0) let isBinding = text.withCString { ptr in keyEvent.text = ptr return ghostty_surface_key_is_binding(surface, keyEvent, &flags) } return isBinding ? flags : nil }() if let bindingFlags { let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 let isAll = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0 let isPerformable = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 // If the binding is consumed and not meant for the menu, allow menu first. if isConsumed && !isAll && !isPerformable && keySequence.isEmpty && keyTables.isEmpty { if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true } } keyDown(with: event) return true } let equivalent: String switch event.charactersIgnoringModifiers { case "\r": // Pass Ctrl+Return through verbatim (prevent context menu equivalent). guard event.modifierFlags.contains(.control) else { return false } equivalent = "\r" case "/": // Treat Ctrl+/ as Ctrl+_ to avoid the system beep. guard event.modifierFlags.contains(.control), event.modifierFlags.isDisjoint(with: [.shift, .command, .option]) else { return false } equivalent = "_" default: // Ignore synthetic events. if event.timestamp == 0 { return false } // Match AppKit key-equivalent routing for menu-style shortcuts (Command-modified). // Control-only terminal input (e.g. Ctrl+D) should not participate in redispatch; // it must flow through the normal keyDown path exactly once. if !event.modifierFlags.contains(.command) { lastPerformKeyEvent = nil return false } if let lastPerformKeyEvent { self.lastPerformKeyEvent = nil if lastPerformKeyEvent == event.timestamp { equivalent = event.charactersIgnoringModifiers ?? "" break } } lastPerformKeyEvent = event.timestamp return false } let finalEvent = NSEvent.keyEvent( with: .keyDown, location: event.locationInWindow, modifierFlags: event.modifierFlags, timestamp: event.timestamp, windowNumber: event.windowNumber, context: nil, characters: equivalent, charactersIgnoringModifiers: equivalent, isARepeat: event.isARepeat, keyCode: event.keyCode ) if let finalEvent { keyDown(with: finalEvent) return true } return false } override func keyDown(with event: NSEvent) { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() let phaseTotalStart = ProcessInfo.processInfo.systemUptime var ensureSurfaceMs: Double = 0 var dismissNotificationMs: Double = 0 var keyboardCopyModeMs: Double = 0 var interpretMs: Double = 0 var syncPreeditMs: Double = 0 var ghosttySendMs: Double = 0 var refreshMs: Double = 0 defer { let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 CmuxTypingTiming.logBreakdown( path: "terminal.keyDown.phase", totalMs: totalMs, event: event, thresholdMs: 1.0, parts: [ ("ensureSurfaceMs", ensureSurfaceMs), ("dismissNotificationMs", dismissNotificationMs), ("keyboardCopyModeMs", keyboardCopyModeMs), ("interpretMs", interpretMs), ("syncPreeditMs", syncPreeditMs), ("ghosttySendMs", ghosttySendMs), ("refreshMs", refreshMs), ], extra: "marked=\(hasMarkedText() ? 1 : 0)" ) CmuxTypingTiming.logDuration(path: "terminal.keyDown", startedAt: typingTimingStart, event: event) } let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime #endif guard let surface = ensureSurfaceReadyForInput() else { requestInputRecoveryAfterSurfaceMiss(reason: "keyDown.missingSurface") #if DEBUG ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 #endif super.keyDown(with: event) return } #if DEBUG ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 #endif if let terminalSurface { #if DEBUG let dismissNotificationStart = ProcessInfo.processInfo.systemUptime #endif AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( tabId: terminalSurface.tabId, surfaceId: terminalSurface.id ) #if DEBUG dismissNotificationMs = (ProcessInfo.processInfo.systemUptime - dismissNotificationStart) * 1000.0 #endif } if event.keyCode != 53 { endFindEscapeSuppression() } if shouldConsumeSuppressedFindEscape(event) { return } #if DEBUG let keyboardCopyModeStart = ProcessInfo.processInfo.systemUptime #endif if handleKeyboardCopyModeIfNeeded(event, surface: surface) { #if DEBUG keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 #endif keyboardCopyModeConsumedKeyUps.insert(event.keyCode) return } #if DEBUG keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 #endif #if DEBUG recordKeyLatency(path: "keyDown", event: event) #endif #if DEBUG cmuxWriteChildExitProbe( [ "probeKeyDownCharsHex": cmuxScalarHex(event.characters), "probeKeyDownCharsIgnoringHex": cmuxScalarHex(event.charactersIgnoringModifiers), "probeKeyDownKeyCode": String(event.keyCode), "probeKeyDownModsRaw": String(event.modifierFlags.rawValue), "probeKeyDownSurfaceId": terminalSurface?.id.uuidString ?? "", ], increments: ["probeKeyDownCount": 1] ) #endif // Fast path for control-modified terminal input (for example Ctrl+D). // // These keys are terminal control input, not text composition, so we bypass // AppKit text interpretation and send a single deterministic Ghostty key event. // This avoids intermittent drops after rapid split close/reparent transitions. let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if flags.contains(.control) && !flags.contains(.command) && !flags.contains(.option) && !hasMarkedText() { terminalSurface?.recordExternalFocusState(true) ghostty_surface_set_focus(surface, true) var keyEvent = ghostty_input_key_s() keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.composing = false keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) let text = (event.charactersIgnoringModifiers ?? event.characters ?? "") let handled: Bool if text.isEmpty { keyEvent.text = nil #if DEBUG let ghosttySendStart = ProcessInfo.processInfo.systemUptime handled = sendTimedGhosttyKey( surface, keyEvent, path: "terminal.keyDown.ctrlGhosttySend", event: event ) ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 #else handled = ghostty_surface_key(surface, keyEvent) #endif } else { #if DEBUG let sendTimingStart = CmuxTypingTiming.start() let ghosttySendStart = ProcessInfo.processInfo.systemUptime #endif handled = text.withCString { ptr in keyEvent.text = ptr return ghostty_surface_key(surface, keyEvent) } #if DEBUG ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 CmuxTypingTiming.logDuration( path: "terminal.keyDown.ctrlGhosttySend", startedAt: sendTimingStart, event: event, extra: "handled=\(handled ? 1 : 0)" ) #endif } #if DEBUG dlog( "key.ctrl path=ghostty surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "handled=\(handled ? 1 : 0) keyCode=\(event.keyCode) chars=\(cmuxScalarHex(event.characters)) " + "ign=\(cmuxScalarHex(event.charactersIgnoringModifiers)) mods=\(event.modifierFlags.rawValue)" ) #endif // If Ghostty handled the key (action/encoding), we're done. // If not (e.g. `ignore` keybind), fall through to interpretKeyEvents // so the IME gets a chance to process this event. if handled { return } } let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS // Translate mods to respect Ghostty config (e.g., macos-option-as-alt) let translationModsGhostty = ghostty_surface_key_translation_mods(surface, modsFromEvent(event)) var translationMods = event.modifierFlags for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { let hasFlag: Bool switch flag { case .shift: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 case .control: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 case .option: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 case .command: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 default: hasFlag = translationMods.contains(flag) } if hasFlag { translationMods.insert(flag) } else { translationMods.remove(flag) } } let translationEvent: NSEvent if translationMods == event.modifierFlags { translationEvent = event } else { translationEvent = NSEvent.keyEvent( with: event.type, location: event.locationInWindow, modifierFlags: translationMods, timestamp: event.timestamp, windowNumber: event.windowNumber, context: nil, characters: event.characters(byApplyingModifiers: translationMods) ?? "", charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", isARepeat: event.isARepeat, keyCode: event.keyCode ) ?? event } // Set up text accumulator for interpretKeyEvents keyTextAccumulator = [] defer { keyTextAccumulator = nil } // Track whether we had marked text (IME preedit) before this event, // so we can detect when composition ends. let markedTextBefore = markedText.length > 0 // Capture the keyboard layout ID before interpretation so we can // detect if an IME changed it (e.g. toggling input methods). // We only check when not already in a preedit state. let keyboardIdBefore: String? = if (!markedTextBefore) { KeyboardLayout.id } else { nil } // Let the input system handle the event (for IME, dead keys, etc.) #if DEBUG let interpretTimingStart = CmuxTypingTiming.start() let interpretPhaseStart = ProcessInfo.processInfo.systemUptime #endif interpretKeyEvents([translationEvent]) #if DEBUG interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0 CmuxTypingTiming.logDuration( path: "terminal.keyDown.interpretKeyEvents", startedAt: interpretTimingStart, event: event ) #endif // If the keyboard layout changed, an input method grabbed the event. // Sync preedit and return without sending the key to Ghostty. if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id { #if DEBUG let syncPreeditStart = ProcessInfo.processInfo.systemUptime #endif syncPreedit(clearIfNeeded: markedTextBefore) #if DEBUG syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 #endif return } // Sync the preedit state with Ghostty so it can render the IME // composition overlay (e.g. for Korean, Japanese, Chinese input). #if DEBUG let syncPreeditStart = ProcessInfo.processInfo.systemUptime #endif syncPreedit(clearIfNeeded: markedTextBefore) #if DEBUG syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 #endif // Build the key event var keyEvent = ghostty_input_key_s() keyEvent.action = action keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) // Control and Command never contribute to text translation keyEvent.consumed_mods = consumedModsFromFlags(translationMods) keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) // We're composing if we have preedit (the obvious case). But we're also // composing if we don't have preedit and we had marked text before, // because this input probably just reset the preedit state. It shouldn't // be encoded. Example: Japanese begin composing, then press backspace. // This should only cancel the composing state but not actually delete // the prior input characters (prior to the composing). keyEvent.composing = markedText.length > 0 || markedTextBefore // Use accumulated text from insertText (for IME), or compute text for key let accumulatedText = keyTextAccumulator ?? [] var shouldRefreshAfterTextInput = false if !accumulatedText.isEmpty { // Accumulated text comes from insertText (IME composition result). // These never have "composing" set to true because these are the // result of a composition. keyEvent.composing = false for text in accumulatedText { if shouldSendText(text) { shouldRefreshAfterTextInput = true #if DEBUG let sendTimingStart = CmuxTypingTiming.start() let ghosttySendStart = ProcessInfo.processInfo.systemUptime #endif text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } #if DEBUG ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 CmuxTypingTiming.logDuration( path: "terminal.keyDown.accumulatedGhosttySend", startedAt: sendTimingStart, event: event, extra: "textBytes=\(text.utf8.count)" ) #endif } else { keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil #if DEBUG let ghosttySendStart = ProcessInfo.processInfo.systemUptime _ = sendTimedGhosttyKey( surface, keyEvent, path: "terminal.keyDown.accumulatedGhosttySend", event: event ) ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 #else _ = ghostty_surface_key(surface, keyEvent) #endif } } if shouldSendCommittedIMEConfirmKey( event: translationEvent, markedTextBefore: markedTextBefore ) { keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil #if DEBUG let ghosttySendStart = ProcessInfo.processInfo.systemUptime _ = sendTimedGhosttyKey( surface, keyEvent, path: "terminal.keyDown.accumulatedConfirmGhosttySend", event: event ) ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 #else _ = ghostty_surface_key(surface, keyEvent) #endif } } else { // Get the appropriate text for this key event // For control characters, this returns the unmodified character // so Ghostty's KeyEncoder can handle ctrl encoding let suppressShiftSpaceFallbackText = shouldSuppressShiftSpaceFallbackText( event: translationEvent, markedTextBefore: markedTextBefore ) if let text = textForKeyEvent(translationEvent) { if shouldSendText(text), !suppressShiftSpaceFallbackText { shouldRefreshAfterTextInput = true #if DEBUG let sendTimingStart = CmuxTypingTiming.start() let ghosttySendStart = ProcessInfo.processInfo.systemUptime #endif text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } #if DEBUG ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 CmuxTypingTiming.logDuration( path: "terminal.keyDown.ghosttySend", startedAt: sendTimingStart, event: event, extra: "textBytes=\(text.utf8.count)" ) #endif } else { keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil #if DEBUG let ghosttySendStart = ProcessInfo.processInfo.systemUptime _ = sendTimedGhosttyKey( surface, keyEvent, path: "terminal.keyDown.ghosttySend", event: event ) ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 #else _ = ghostty_surface_key(surface, keyEvent) #endif } } else { keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil #if DEBUG let ghosttySendStart = ProcessInfo.processInfo.systemUptime _ = sendTimedGhosttyKey( surface, keyEvent, path: "terminal.keyDown.ghosttySend", event: event ) ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 #else _ = ghostty_surface_key(surface, keyEvent) #endif } } if shouldRefreshAfterTextInput { #if DEBUG let refreshStart = ProcessInfo.processInfo.systemUptime #endif terminalSurface?.forceRefresh(reason: "keyDown.textInput") #if DEBUG refreshMs = (ProcessInfo.processInfo.systemUptime - refreshStart) * 1000.0 #endif } // Rendering is driven by Ghostty's wakeups/renderer. } @discardableResult private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool { #if DEBUG Self.debugGhosttySurfaceKeyEventObserver?(keyEvent) #endif return ghostty_surface_key(surface, keyEvent) } #if DEBUG @discardableResult private func sendTimedGhosttyKey( _ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s, path: String, event: NSEvent? = nil, extra: String? = nil ) -> Bool { let timingStart = CmuxTypingTiming.start() let handled = sendGhosttyKey(surface, keyEvent) let baseExtra = "handled=\(handled ? 1 : 0)" let mergedExtra: String if let extra, !extra.isEmpty { mergedExtra = "\(baseExtra) \(extra)" } else { mergedExtra = baseExtra } CmuxTypingTiming.logDuration(path: path, startedAt: timingStart, event: event, extra: mergedExtra) return handled } #endif override func keyUp(with event: NSEvent) { guard let surface = ensureSurfaceReadyForInput() else { super.keyUp(with: event) return } if event.keyCode != 53 { endFindEscapeSuppression() } if shouldConsumeSuppressedFindEscape(event) { endFindEscapeSuppression() return } if event.keyCode == 53 { endFindEscapeSuppression() } if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil { return } // Build release events from the same translation path as keyDown so // consumers that depend on precise key identity (for example Space // hold/release flows) receive consistent metadata. var keyEvent = ghosttyKeyEvent(for: event, surface: surface) keyEvent.action = GHOSTTY_ACTION_RELEASE keyEvent.text = nil keyEvent.composing = false _ = sendGhosttyKey(surface, keyEvent) } override func flagsChanged(with event: NSEvent) { guard let surface = surface else { super.flagsChanged(with: event) return } var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil keyEvent.composing = false _ = ghostty_surface_key(surface, keyEvent) // Refresh ghostty's mouse position so quicklook_word uses current coordinates // when Cmd is pressed while the pointer is stationary. let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) updateWordPathHover(cmdHeld: event.modifierFlags.contains(.command)) } private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e { var mods = GHOSTTY_MODS_NONE.rawValue if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue } if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } return ghostty_input_mods_e(rawValue: mods) } /// Consumed mods are modifiers that were used for text translation. /// Control and Command never contribute to text translation, so they /// should be excluded from consumed_mods. private func consumedModsFromFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var mods = GHOSTTY_MODS_NONE.rawValue // Only include Shift and Option as potentially consumed // Control and Command are never consumed for text translation if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } return ghostty_input_mods_e(rawValue: mods) } func beginFindEscapeSuppression() { isFindEscapeSuppressionArmed = true } private func endFindEscapeSuppression() { isFindEscapeSuppressionArmed = false } private func shouldConsumeSuppressedFindEscape(_ event: NSEvent) -> Bool { guard event.keyCode == 53 else { return false } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) guard flags.isEmpty else { return false } return isFindEscapeSuppressionArmed } /// Get the characters for a key event with control character handling. /// When control is pressed, we get the character without the control modifier /// so Ghostty's KeyEncoder can apply its own control character encoding. private func textForKeyEvent(_ event: NSEvent) -> String? { guard let chars = event.characters, !chars.isEmpty else { return nil } if chars.count == 1, let scalar = chars.unicodeScalars.first { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) // If we have a single control character, return the character without // the control modifier so Ghostty's KeyEncoder can handle it. if isControlCharacterScalar(scalar) { if flags.contains(.control) { return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) } // Some AppKit key paths can report Shift+` as a bare ESC control // character even though the physical key should produce "~". if scalar.value == 0x1B, flags == [.shift], event.charactersIgnoringModifiers == "`" { return "~" } } // Private Use Area characters (function keys) should not be sent if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { return nil } } return chars } /// Get the unshifted codepoint for the key event private func unshiftedCodepointFromEvent(_ event: NSEvent) -> UInt32 { if let layoutChars = KeyboardLayout.character(forKeyCode: event.keyCode), layoutChars.count == 1, let layoutScalar = layoutChars.unicodeScalars.first, layoutScalar.value >= 0x20, !(layoutScalar.value >= 0xF700 && layoutScalar.value <= 0xF8FF) { return layoutScalar.value } guard let chars = (event.characters(byApplyingModifiers: []) ?? event.charactersIgnoringModifiers ?? event.characters), let scalar = chars.unicodeScalars.first else { return 0 } return scalar.value } private func isControlCharacterScalar(_ scalar: UnicodeScalar) -> Bool { scalar.value < 0x20 || scalar.value == 0x7F } private func shouldSendText(_ text: String) -> Bool { guard !text.isEmpty else { return false } if text.count == 1, let scalar = text.unicodeScalars.first { return !isControlCharacterScalar(scalar) } return true } /// If AppKit consumed Shift+Space for IME/input-source switching, interpretKeyEvents /// can return without insertText and without a detectable layout ID change. /// In that case we must not synthesize a literal space fallback. private func shouldSuppressShiftSpaceFallbackText(event: NSEvent, markedTextBefore: Bool) -> Bool { guard event.keyCode == 49 else { return false } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) guard flags == [.shift] else { return false } guard !markedTextBefore, markedText.length == 0 else { return false } return true } private func shouldSendCommittedIMEConfirmKey(event: NSEvent, markedTextBefore: Bool) -> Bool { guard markedTextBefore, markedText.length == 0 else { return false } guard event.keyCode == 36 || event.keyCode == 76 else { return false } // Korean IME: Enter commits the syllable AND executes the command (single step). // Japanese/Chinese IME: Enter only confirms the conversion; a second Enter executes. // Only send the extra Return key for Korean input sources. guard let sourceId = KeyboardLayout.id else { return false } return sourceId.range(of: "korean", options: .caseInsensitive) != nil } private func ghosttyKeyEvent(for event: NSEvent, surface: ghostty_surface_t) -> ghostty_input_key_s { var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) // Translate mods to respect Ghostty config (e.g., macos-option-as-alt). let translationModsGhostty = ghostty_surface_key_translation_mods(surface, modsFromEvent(event)) var translationMods = event.modifierFlags for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { let hasFlag: Bool switch flag { case .shift: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 case .control: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 case .option: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 case .command: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 default: hasFlag = translationMods.contains(flag) } if hasFlag { translationMods.insert(flag) } else { translationMods.remove(flag) } } keyEvent.consumed_mods = consumedModsFromFlags(translationMods) keyEvent.text = nil keyEvent.composing = false keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) return keyEvent } func updateKeySequence(_ action: ghostty_action_key_sequence_s) { if action.active { keySequence.append(action.trigger) } else { keySequence.removeAll() } } func updateKeyTable(_ action: ghostty_action_key_table_s) { switch action.tag { case GHOSTTY_KEY_TABLE_ACTIVATE: let namePtr = action.value.activate.name let nameLen = Int(action.value.activate.len) let name: String if let namePtr, nameLen > 0 { let data = Data(bytes: namePtr, count: nameLen) name = String(data: data, encoding: .utf8) ?? "" } else { name = "" } keyTables.append(name) case GHOSTTY_KEY_TABLE_DEACTIVATE: _ = keyTables.popLast() case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL: keyTables.removeAll() default: break } terminalSurface?.hostedView.syncKeyStateIndicator(text: currentKeyStateIndicatorText) } // MARK: - Mouse Handling #if DEBUG private func debugModifierString(_ flags: NSEvent.ModifierFlags) -> String { [ flags.contains(.command) ? "cmd" : nil, flags.contains(.shift) ? "shift" : nil, flags.contains(.control) ? "ctrl" : nil, flags.contains(.option) ? "opt" : nil, ].compactMap { $0 }.joined(separator: "+") } #endif private func requestPointerFocusRecovery() { #if DEBUG dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif onFocus?() } override func mouseDown(with event: NSEvent) { #if DEBUG let debugPoint = convert(event.locationInWindow, from: nil) dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") #endif // Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus` // callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still // repairs workspace/pane active state before key routing runs. requestPointerFocusRecovery() window?.makeFirstResponder(self) if let terminalSurface { AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( tabId: terminalSurface.tabId, surfaceId: terminalSurface.id ) } guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) // Only update mouse position on the first click to prevent unwanted cursor // movement during double-click selection (issue #1698) if event.clickCount == 1 { ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) } override func mouseUp(with event: NSEvent) { #if DEBUG dlog("terminal.mouseUp surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))]") #endif guard let surface = surface else { return } let consumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) // Fallback: if Cmd was held and ghostty didn't handle the click as a link, // check if the word under cursor is a valid file/directory in the terminal's CWD. // This enables cmd-click on bare filenames from commands like `ls`. if !consumed && event.modifierFlags.contains(.command) { // Refresh ghostty's cached mouse position so quicklook_word reads // up-to-date coordinates (mouseDown skips pos update on double-click). let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) tryOpenWordAsPath() } } /// Attempt to open the word under the mouse cursor as a file path, resolved /// against the terminal panel's current working directory. private func tryOpenWordAsPath() { guard let resolvedPath = resolveWordUnderCursorAsPath() else { return } #if DEBUG dlog("link.wordFallback resolved=\(resolvedPath)") #endif PreferredEditorSettings.open(URL(fileURLWithPath: resolvedPath)) } /// Check if the word under the mouse cursor resolves to an existing file/directory /// in the terminal panel's CWD. Returns the resolved absolute path, or nil. private func resolveWordUnderCursorAsPath() -> String? { guard let surface = surface else { return nil } var text = ghostty_text_s() guard ghostty_surface_quicklook_word(surface, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } guard text.text_len > 0, let ptr = text.text else { return nil } let wordData = Data(bytes: ptr, count: Int(text.text_len)) guard let decodedWord = String(bytes: wordData, encoding: .utf8) else { return nil } let word = decodedWord.trimmingCharacters(in: .whitespacesAndNewlines) guard !word.isEmpty, !word.hasPrefix("/") else { return nil } guard let termSurface = terminalSurface, let workspace = termSurface.owningWorkspace(), !workspace.isRemoteTerminalSurface(termSurface.id) else { return nil } // Use the same CWD fallback chain as Workspace split creation: // panelDirectories (live OSC 7) → requestedWorkingDirectory → workspace currentDirectory let cwd: String? = { if let dir = workspace.panelDirectories[termSurface.id]?.trimmingCharacters(in: .whitespacesAndNewlines), !dir.isEmpty { return dir } if let dir = workspace.terminalPanel(for: termSurface.id)? .requestedWorkingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !dir.isEmpty { return dir } let dir = workspace.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) return dir.isEmpty ? nil : dir }() guard let cwd else { return nil } let resolvedPath = (cwd as NSString).appendingPathComponent(word) guard FileManager.default.fileExists(atPath: resolvedPath) else { return nil } return resolvedPath } /// Update the pointing-hand cursor when Cmd-hovering over a bare filename /// that exists in the terminal's CWD. private func updateWordPathHover(cmdHeld: Bool) { guard cmdHeld else { if wordPathHoverActive { wordPathHoverActive = false NSCursor.pop() } return } if resolveWordUnderCursorAsPath() != nil { if !wordPathHoverActive { wordPathHoverActive = true NSCursor.pointingHand.push() } } else if wordPathHoverActive { wordPathHoverActive = false NSCursor.pop() } } override func rightMouseDown(with event: NSEvent) { guard let surface = surface else { return } if !ghostty_surface_mouse_captured(surface) { requestPointerFocusRecovery() super.rightMouseDown(with: event) return } requestPointerFocusRecovery() window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) } override func rightMouseUp(with event: NSEvent) { guard let surface = surface else { return } if !ghostty_surface_mouse_captured(surface) { super.rightMouseUp(with: event) return } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) } override func otherMouseDown(with event: NSEvent) { guard event.buttonNumber == 2 else { super.otherMouseDown(with: event) return } requestPointerFocusRecovery() window?.makeFirstResponder(self) guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, modsFromEvent(event)) } override func otherMouseUp(with event: NSEvent) { guard event.buttonNumber == 2 else { super.otherMouseUp(with: event) return } guard let surface = surface else { return } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, modsFromEvent(event)) } override func menu(for event: NSEvent) -> NSMenu? { guard let surface = surface else { return nil } if ghostty_surface_mouse_captured(surface) { return nil } window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) let menu = NSMenu() if onTriggerFlash != nil { let flashItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.triggerFlash", defaultValue: "Trigger Flash"), action: #selector(triggerFlash(_:)), keyEquivalent: "" ) flashItem.target = self menu.addItem(.separator()) } if ghostty_surface_has_selection(surface) { let item = menu.addItem( withTitle: String(localized: "terminalContextMenu.copy", defaultValue: "Copy"), action: #selector(copy(_:)), keyEquivalent: "" ) item.target = self } let pasteItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.paste", defaultValue: "Paste"), action: #selector(paste(_:)), keyEquivalent: "" ) pasteItem.target = self menu.addItem(.separator()) let splitHorizontallyItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.splitHorizontally", defaultValue: "Split Horizontally"), action: #selector(splitHorizontally(_:)), keyEquivalent: "d" ) splitHorizontallyItem.target = self splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift] splitHorizontallyItem.image = NSImage( systemSymbolName: "rectangle.bottomhalf.inset.filled", accessibilityDescription: nil ) let splitVerticallyItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.splitVertically", defaultValue: "Split Vertically"), action: #selector(splitVertically(_:)), keyEquivalent: "d" ) splitVerticallyItem.target = self splitVerticallyItem.keyEquivalentModifierMask = [.command] splitVerticallyItem.image = NSImage( systemSymbolName: "rectangle.righthalf.inset.filled", accessibilityDescription: nil ) menu.addItem(.separator()) let resetTerminalItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.resetTerminal", defaultValue: "Reset Terminal"), action: #selector(resetTerminal(_:)), keyEquivalent: "" ) resetTerminalItem.target = self resetTerminalItem.image = NSImage( systemSymbolName: "arrow.trianglehead.2.clockwise", accessibilityDescription: nil ) return menu } private func canSplitCurrentSurface() -> Bool { guard let tabId, let surfaceId = terminalSurface?.id, let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == tabId }) else { return false } return workspace.panels[surfaceId] != nil } @objc private func splitHorizontally(_ sender: Any?) { _ = splitCurrentSurface(direction: .down) } @objc private func splitVertically(_ sender: Any?) { _ = splitCurrentSurface(direction: .right) } @discardableResult private func splitCurrentSurface(direction: SplitDirection) -> Bool { guard let tabId, let surfaceId = terminalSurface?.id, let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } return manager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } @objc private func triggerFlash(_ sender: Any?) { onTriggerFlash?() } @objc private func resetTerminal(_ sender: Any?) { _ = performBindingAction("reset") } override func mouseMoved(with event: NSEvent) { maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) updateWordPathHover(cmdHeld: event.modifierFlags.contains(.command)) } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) } private func maybeRequestFirstResponderForMouseFocus() { guard let window else { return } let alreadyFirstResponder = window.firstResponder === self let shouldRequest = Self.shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: GhosttyApp.shared.focusFollowsMouseEnabled(), pressedMouseButtons: NSEvent.pressedMouseButtons, appIsActive: NSApp.isActive, windowIsKey: window.isKeyWindow, alreadyFirstResponder: alreadyFirstResponder, visibleInUI: isVisibleInUI, hasUsableGeometry: hasUsableFocusGeometry, hiddenInHierarchy: isHiddenOrHasHiddenAncestor ) guard shouldRequest else { return } window.makeFirstResponder(self) } override func mouseExited(with event: NSEvent) { if wordPathHoverActive { wordPathHoverActive = false NSCursor.pop() } guard let surface = surface else { return } if NSEvent.pressedMouseButtons != 0 { return } ghostty_surface_mouse_pos(surface, -1, -1, modsFromEvent(event)) } override func mouseDragged(with event: NSEvent) { guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) } override func scrollWheel(with event: NSEvent) { NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self) guard let surface = surface else { return } lastScrollEventTime = CACurrentMediaTime() Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))") var x = event.scrollingDeltaX var y = event.scrollingDeltaY let precision = event.hasPreciseScrollingDeltas if precision { x *= 2 y *= 2 } var mods: Int32 = 0 if precision { mods |= 0b0000_0001 } let momentum: Int32 switch event.momentumPhase { case .began: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_BEGAN.rawValue) case .stationary: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_STATIONARY.rawValue) case .changed: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_CHANGED.rawValue) case .ended: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_ENDED.rawValue) case .cancelled: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_CANCELLED.rawValue) case .mayBegin: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN.rawValue) default: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_NONE.rawValue) } mods |= momentum << 1 // Track scroll state for lag detection let hasMomentum = event.momentumPhase != [] && event.momentumPhase != .mayBegin let momentumEnded = event.momentumPhase == .ended || event.momentumPhase == .cancelled GhosttyApp.shared.markScrollActivity(hasMomentum: hasMomentum, momentumEnded: momentumEnded) ghostty_surface_mouse_scroll( surface, x, y, ghostty_input_scroll_mods_t(mods) ) } deinit { // Surface lifecycle is managed by TerminalSurface, not the view #if DEBUG dlog( "surface.view.deinit view=\(Unmanaged.passUnretained(self).toOpaque()) " + "surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0)" ) #endif if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) } if let trackingArea { removeTrackingArea(trackingArea) } terminalSurface = nil } override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingArea { removeTrackingArea(trackingArea) } trackingArea = NSTrackingArea( rect: bounds, options: [ .mouseEnteredAndExited, .mouseMoved, .inVisibleRect, .activeAlways, ], owner: self, userInfo: nil ) if let trackingArea { addTrackingArea(trackingArea) } } private func windowDidChangeScreen(_ notification: Notification) { guard let window else { return } guard let object = notification.object as? NSWindow, window == object else { return } guard let screen = window.screen else { return } guard let surface = terminalSurface?.surface else { return } if let displayID = screen.displayID, displayID != 0 { ghostty_surface_set_display_id(surface, displayID) } DispatchQueue.main.async { [weak self] in self?.viewDidChangeBackingProperties() } } fileprivate static func escapeDropForShell(_ value: String) -> String { TerminalImageTransferPlanner.escapeForShell(value) } static func dropPlanForTesting( pasteboard: NSPasteboard, isRemoteTerminalSurface: Bool ) -> DropPlan { let target: TerminalImageTransferTarget = isRemoteTerminalSurface ? .remote(.workspaceRemote) : .local switch TerminalImageTransferPlanner.plan( pasteboard: pasteboard, mode: .drop, target: target ) { case .insertText(let text): return .insertText(text) case .uploadFiles(let fileURLs, _): return .uploadFiles(fileURLs) case .reject: return .reject } } static func performRemoteDropUploadForTesting( upload: (@escaping (Result<[String], Error>) -> Void) -> Void, sendText: @escaping (String) -> Void, onFailure: @escaping () -> Void ) { upload { result in switch result { case .success(let remotePaths): let content = remotePaths .map { Self.escapeDropForShell($0) } .joined(separator: " ") guard !content.isEmpty else { onFailure() return } sendText(content) case .failure: onFailure() } } } @discardableResult static func handleDropForTesting( pasteboard: NSPasteboard, isRemoteTerminalSurface: Bool, uploadRemote: ([URL], @escaping (Result<[String], Error>) -> Void) -> Void, sendText: @escaping (String) -> Void, onFailure: @escaping () -> Void ) -> Bool { let target: TerminalImageTransferTarget = isRemoteTerminalSurface ? .remote(.workspaceRemote) : .local let plan = TerminalImageTransferPlanner.plan( pasteboard: pasteboard, mode: .drop, target: target ) guard plan != .reject else { return false } TerminalImageTransferPlanner.execute( plan: plan, uploadWorkspaceRemote: { urls, _, finish in uploadRemote(urls) { result in finish(result) GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(urls) } }, uploadDetectedSSH: { _, _, _, finish in finish(.failure(NSError(domain: "cmux.remote.drop", code: 4))) }, insertText: sendText, onFailure: { _ in onFailure() } ) return true } private func executeImageTransferPlan( _ plan: TerminalImageTransferPlan, operation: TerminalImageTransferOperation? = nil, onCancel: @escaping () -> Void = {} ) -> Bool { guard plan != .reject else { return false } let operation = operation ?? { if case .uploadFiles = plan { return TerminalImageTransferOperation() } return nil }() if let operation { terminalSurface?.hostedView.beginImageTransferIndicator( for: operation, onCancel: onCancel ) } TerminalImageTransferPlanner.execute( plan: plan, operation: operation, uploadWorkspaceRemote: { [weak self] fileURLs, operation, finish in guard let workspace = MainActor.assumeIsolated({ self?.terminalSurface?.owningWorkspace() }) else { finish(.failure(NSError(domain: "cmux.remote.drop", code: 3))) GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) return } workspace.uploadDroppedFilesForRemoteTerminal( fileURLs, operation: operation, completion: { result in finish(result) GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) } ) }, uploadDetectedSSH: { session, fileURLs, operation, finish in session.uploadDroppedFiles( fileURLs, operation: operation, completion: { result in finish(result) GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) } ) }, insertText: { [weak self] text in let send = { if let operation { self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation) } // Use the text/paste path (ghostty_surface_text) instead of the key event // path (ghostty_surface_key) so bracketed paste mode is triggered and the // insertion is instant, matching upstream Ghostty behaviour. self?.terminalSurface?.sendText(text) } if Thread.isMainThread { send() } else { DispatchQueue.main.async(execute: send) } }, onFailure: { [weak self] _ in if let operation { self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation) } DispatchQueue.main.async { NSSound.beep() #if DEBUG dlog("terminal.remoteDropUpload.failed surface=\(self?.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif } } ) return true } private func resolvedImageTransferTarget() -> TerminalImageTransferTarget { MainActor.assumeIsolated { terminalSurface?.resolvedImageTransferTarget() ?? .local } } fileprivate func handleDroppedFileURLs(_ urls: [URL]) -> Bool { executePreparedImageTransfer( .fileURLs(urls), onCancel: {} ) } @discardableResult fileprivate func insertDroppedPasteboard(_ pasteboard: NSPasteboard) -> Bool { executePreparedImageTransfer( TerminalImageTransferPlanner.prepare( pasteboard: pasteboard, mode: .drop ), onCancel: {} ) } @discardableResult private func executePreparedImageTransfer( _ preparedContent: TerminalImageTransferPreparedContent, onCancel: @escaping () -> Void ) -> Bool { switch preparedContent { case .reject: return false case .insertText(let text): terminalSurface?.sendText(text) return true case .fileURLs(let fileURLs): let plan = TerminalImageTransferPlanner.plan( fileURLs: fileURLs, target: resolvedImageTransferTarget() ) return executeImageTransferPlan(plan, onCancel: onCancel) } } #if DEBUG @discardableResult fileprivate func debugSimulateFileDrop(paths: [String]) -> Bool { guard !paths.isEmpty else { return false } let urls = paths.map { URL(fileURLWithPath: $0) as NSURL } let pbName = NSPasteboard.Name("cmux.debug.drop.\(UUID().uuidString)") let pasteboard = NSPasteboard(name: pbName) pasteboard.clearContents() pasteboard.writeObjects(urls) return insertDroppedPasteboard(pasteboard) } fileprivate func debugRegisteredDropTypes() -> [String] { (registeredDraggedTypes ?? []).map(\.rawValue) } #endif // MARK: NSDraggingDestination override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { #if DEBUG let types = sender.draggingPasteboard.types ?? [] dlog("terminal.draggingEntered surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } return .copy } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { #if DEBUG let types = sender.draggingPasteboard.types ?? [] dlog("terminal.draggingUpdated surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } return .copy } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { #if DEBUG dlog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif return insertDroppedPasteboard(sender.draggingPasteboard) } } private extension NSScreen { var displayID: UInt32? { let key = NSDeviceDescriptionKey("NSScreenNumber") if let v = deviceDescription[key] as? UInt32 { return v } if let v = deviceDescription[key] as? Int { return UInt32(v) } if let v = deviceDescription[key] as? NSNumber { return v.uint32Value } return nil } } struct GhosttyScrollbar { let total: UInt64 let offset: UInt64 let len: UInt64 init(c: ghostty_action_scrollbar_s) { total = c.total offset = c.offset len = c.len } } enum GhosttyNotificationKey { static let scrollbar = "ghostty.scrollbar" static let cellSize = "ghostty.cellSize" static let tabId = "ghostty.tabId" static let surfaceId = "ghostty.surfaceId" static let title = "ghostty.title" static let backgroundColor = "ghostty.backgroundColor" static let backgroundOpacity = "ghostty.backgroundOpacity" static let backgroundEventId = "ghostty.backgroundEventId" static let backgroundSource = "ghostty.backgroundSource" } extension Notification.Name { static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar") static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize") static let ghosttyDidReceiveWheelScroll = Notification.Name("ghosttyDidReceiveWheelScroll") static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus") static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload") static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange") static let browserSearchFocus = Notification.Name("browserSearchFocus") } // MARK: - Scroll View Wrapper (Ghostty-style scrollbar) private final class GhosttyScrollView: NSScrollView { weak var surfaceView: GhosttyNSView? // Keep keyboard routing on the terminal surface; this wrapper is viewport plumbing. override var acceptsFirstResponder: Bool { false } override func scrollWheel(with event: NSEvent) { guard let surfaceView else { super.scrollWheel(with: event) return } // Route wheel gestures to the terminal surface so Ghostty handles scrollback. // Letting NSScrollView consume these events moves the wrapper viewport itself, // which causes pane-content drift instead of terminal scrollback movement. GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll") if window?.firstResponder !== surfaceView { window?.makeFirstResponder(surfaceView) } surfaceView.scrollWheel(with: event) } } private final class GhosttyFlashOverlayView: NSView { override var acceptsFirstResponder: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { nil } } private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { override var acceptsFirstResponder: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { nil } } func shouldAllowEnsureFocusWindowActivation( activeTabManager: TabManager?, targetTabManager: TabManager, keyWindow: NSWindow?, mainWindow: NSWindow?, targetWindow: NSWindow ) -> Bool { guard activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil) else { return false } if let keyWindow { return keyWindow === targetWindow } if let mainWindow { return mainWindow === targetWindow } return true } final class GhosttySurfaceScrollView: NSView { enum FlashStyle { case navigation case notification } static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> FlashStyle { switch reason { case .navigation: return .navigation case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: return .notification } } private static func flashPresentation(for style: FlashStyle) -> WorkspaceAttentionFlashPresentation { switch style { case .navigation: return WorkspaceAttentionCoordinator.flashStyle(for: .navigation) case .notification: return WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival) } } private enum NotificationRingMetrics { static let inset = PanelOverlayRingMetrics.inset static let cornerRadius = PanelOverlayRingMetrics.cornerRadius static let lineWidth = PanelOverlayRingMetrics.lineWidth } private let backgroundView: NSView private let scrollView: GhosttyScrollView private let documentView: NSView private let surfaceView: GhosttyNSView private let inactiveOverlayView: GhosttyFlashOverlayView private let dropZoneOverlayView: GhosttyFlashOverlayView private let notificationRingOverlayView: GhosttyFlashOverlayView private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer private var lastFlashStyle: FlashStyle = .navigation private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeIconView: NSImageView private let keyboardCopyModeBadgeLabel: NSTextField private let imageTransferIndicatorContainerView: NSView private let imageTransferIndicatorView: NSVisualEffectView private let imageTransferIndicatorSpinner: NSProgressIndicator private let imageTransferCancelButton: NSButton private var searchOverlayHostingView: NSHostingView? private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem? private var imageTransferIndicatorShowWorkItem: DispatchWorkItem? private var activeImageTransferOperation: TerminalImageTransferOperation? private var activeImageTransferCancelHandler: (() -> Void)? private var lastSearchOverlayStateID: ObjectIdentifier? private var searchOverlayMutationGeneration: UInt64 = 0 private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false private var lastSentRow: Int? /// Tracks whether the user has scrolled away from the bottom to review scrollback. /// When true, auto-scroll should be suspended to prevent the "doomscroll" bug /// where the terminal fights the user's scroll position. private var userScrolledAwayFromBottom = false private var pendingExplicitWheelScroll = false private var allowExplicitScrollbarSync = false /// Threshold in points from bottom to consider "at bottom" (allows for minor float drift) private static let scrollToBottomThreshold: CGFloat = 5.0 private var isActive = true private var lastFocusRefreshAt: CFTimeInterval = 0 private var lastRequestedPortalOcclusionVisible: Bool? private var activeDropZone: DropZone? private var pendingDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 private var pendingAutomaticFirstResponderApply = false // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. /// Tracks whether keyboard focus should go to the search field or the terminal /// when the window becomes key while the find bar is open. enum SearchFocusTarget { case searchField case terminal } private(set) var searchFocusTarget: SearchFocusTarget = .searchField #if DEBUG private var lastDropZoneOverlayLogSignature: String? private var lastDragGeometryLogSignature: String? private var dragLayoutLogSequence: UInt64 = 0 private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder") private static var flashCounts: [UUID: Int] = [:] private static var drawCounts: [UUID: Int] = [:] private static var lastDrawTimes: [UUID: CFTimeInterval] = [:] private static var presentCounts: [UUID: Int] = [:] private static var dropOverlayShowCounts: [UUID: Int] = [:] private static var lastPresentTimes: [UUID: CFTimeInterval] = [:] private static var lastContentsKeys: [UUID: String] = [:] static func flashCount(for surfaceId: UUID) -> Int { flashCounts[surfaceId, default: 0] } static func resetFlashCounts() { flashCounts.removeAll() } private static func recordFlash(for surfaceId: UUID) { flashCounts[surfaceId, default: 0] += 1 } static func drawStats(for surfaceId: UUID) -> (count: Int, last: CFTimeInterval) { (drawCounts[surfaceId, default: 0], lastDrawTimes[surfaceId, default: 0]) } static func resetDrawStats() { drawCounts.removeAll() lastDrawTimes.removeAll() } static func recordSurfaceDraw(_ surfaceId: UUID) { drawCounts[surfaceId, default: 0] += 1 lastDrawTimes[surfaceId] = CACurrentMediaTime() } private static func contentsKey(for layer: CALayer?) -> String { guard let modelLayer = layer else { return "nil" } // Prefer the presentation layer to better reflect what the user sees on screen. let layer = modelLayer.presentation() ?? modelLayer guard let contents = layer.contents else { return "nil" } // Prefer pointer identity for object/CFType contents. if let obj = contents as AnyObject? { let ptr = Unmanaged.passUnretained(obj).toOpaque() var key = "0x" + String(UInt(bitPattern: ptr), radix: 16) // For IOSurface-backed terminal layers, the IOSurface object can remain stable while // its contents change. Include the IOSurface seed so "new frame rendered" is visible // to debug/test tooling even when the pointer identity doesn't change. let cf = contents as CFTypeRef if CFGetTypeID(cf) == IOSurfaceGetTypeID() { let surfaceRef = (contents as! IOSurfaceRef) let seed = IOSurfaceGetSeed(surfaceRef) key += ":seed=\(seed)" } return key } return String(describing: contents) } private static func updatePresentStats(surfaceId: UUID, layer: CALayer?) -> (count: Int, last: CFTimeInterval, key: String) { let key = contentsKey(for: layer) if lastContentsKeys[surfaceId] != key { presentCounts[surfaceId, default: 0] += 1 lastPresentTimes[surfaceId] = CACurrentMediaTime() lastContentsKeys[surfaceId] = key } return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key) } private func recordDropOverlayShowAnimation() { guard let surfaceId = surfaceView.terminalSurface?.id else { return } Self.dropOverlayShowCounts[surfaceId, default: 0] += 1 } func debugProbeDropOverlayAnimation(useDeferredPath: Bool) -> (before: Int, after: Int, bounds: CGSize) { guard let surfaceId = surfaceView.terminalSurface?.id else { return (0, 0, bounds.size) } let before = Self.dropOverlayShowCounts[surfaceId, default: 0] // Reset to a hidden baseline so each probe exercises an initial-show transition. dropZoneOverlayAnimationGeneration &+= 1 activeDropZone = nil pendingDropZone = nil dropZoneOverlayView.layer?.removeAllAnimations() dropZoneOverlayView.isHidden = true dropZoneOverlayView.alphaValue = 1 if useDeferredPath { pendingDropZone = .left synchronizeGeometryAndContent() } else { setDropZoneOverlay(zone: .left) } let after = Self.dropOverlayShowCounts[surfaceId, default: 0] setDropZoneOverlay(zone: nil) return (before, after, bounds.size) } var debugSurfaceId: UUID? { surfaceView.terminalSurface?.id } #endif func portalBindingGuardState() -> (surfaceId: UUID?, generation: UInt64?, state: String) { guard let terminalSurface = surfaceView.terminalSurface else { return (surfaceId: nil, generation: nil, state: "missingSurface") } return ( surfaceId: terminalSurface.id, generation: terminalSurface.portalBindingGeneration(), state: terminalSurface.portalBindingStateLabel() ) } func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { guard let terminalSurface = surfaceView.terminalSurface else { return false } return terminalSurface.canAcceptPortalBinding( expectedSurfaceId: expectedSurfaceId, expectedGeneration: expectedGeneration ) } func releaseOwnedPortalHost(hostId: ObjectIdentifier, reason: String) { surfaceView.terminalSurface?.releasePortalHostIfOwned( hostId: hostId, reason: reason ) } func prepareOwnedPortalHostForTransientReattach(hostId: ObjectIdentifier, reason: String) { surfaceView.terminalSurface?.preparePortalHostReplacementIfOwned( hostId: hostId, reason: reason ) } init(surfaceView: GhosttyNSView) { self.surfaceView = surfaceView backgroundView = NSView(frame: .zero) scrollView = GhosttyScrollView() inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero) dropZoneOverlayView = GhosttyFlashOverlayView(frame: .zero) notificationRingOverlayView = GhosttyFlashOverlayView(frame: .zero) notificationRingLayer = CAShapeLayer() flashOverlayView = GhosttyFlashOverlayView(frame: .zero) flashLayer = CAShapeLayer() keyboardCopyModeBadgeContainerView = GhosttyFlashOverlayView(frame: .zero) keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero) keyboardCopyModeBadgeIconView = NSImageView(frame: .zero) keyboardCopyModeBadgeLabel = NSTextField(labelWithString: terminalKeyboardCopyModeIndicatorText) imageTransferIndicatorContainerView = NSView(frame: .zero) imageTransferIndicatorView = NSVisualEffectView(frame: .zero) imageTransferIndicatorSpinner = NSProgressIndicator(frame: .zero) imageTransferCancelButton = NSButton(frame: .zero) scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = false scrollView.usesPredominantAxisScrolling = true scrollView.scrollerStyle = .overlay scrollView.drawsBackground = false scrollView.backgroundColor = .clear scrollView.contentView.clipsToBounds = true scrollView.contentView.drawsBackground = false scrollView.contentView.backgroundColor = .clear scrollView.surfaceView = surfaceView documentView = NSView(frame: .zero) scrollView.documentView = documentView documentView.addSubview(surfaceView) super.init(frame: .zero) wantsLayer = true layer?.masksToBounds = true backgroundView.wantsLayer = true let initialTerminalBackground = GhosttyApp.shared.defaultBackgroundColor .withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity) backgroundView.layer?.backgroundColor = initialTerminalBackground.cgColor backgroundView.layer?.isOpaque = initialTerminalBackground.alphaComponent >= 1.0 addSubview(backgroundView) addSubview(scrollView) inactiveOverlayView.wantsLayer = true inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor inactiveOverlayView.isHidden = true addSubview(inactiveOverlayView) dropZoneOverlayView.wantsLayer = true dropZoneOverlayView.layer?.backgroundColor = cmuxAccentNSColor().withAlphaComponent(0.25).cgColor dropZoneOverlayView.layer?.borderColor = cmuxAccentNSColor().cgColor dropZoneOverlayView.layer?.borderWidth = 2 dropZoneOverlayView.layer?.cornerRadius = 8 dropZoneOverlayView.isHidden = true notificationRingOverlayView.wantsLayer = true notificationRingOverlayView.layer?.backgroundColor = NSColor.clear.cgColor notificationRingOverlayView.layer?.masksToBounds = false notificationRingOverlayView.autoresizingMask = [.width, .height] notificationRingLayer.fillColor = NSColor.clear.cgColor notificationRingLayer.strokeColor = NSColor.systemBlue.cgColor notificationRingLayer.lineWidth = NotificationRingMetrics.lineWidth notificationRingLayer.lineJoin = .round notificationRingLayer.lineCap = .round notificationRingLayer.shadowColor = NSColor.systemBlue.cgColor notificationRingLayer.shadowOpacity = 0.35 notificationRingLayer.shadowRadius = 3 notificationRingLayer.shadowOffset = .zero notificationRingLayer.opacity = 0 notificationRingOverlayView.layer?.addSublayer(notificationRingLayer) notificationRingOverlayView.isHidden = true addSubview(notificationRingOverlayView) flashOverlayView.wantsLayer = true flashOverlayView.layer?.backgroundColor = NSColor.clear.cgColor flashOverlayView.layer?.masksToBounds = false flashOverlayView.autoresizingMask = [.width, .height] flashLayer.fillColor = NSColor.clear.cgColor flashLayer.strokeColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor flashLayer.lineWidth = NotificationRingMetrics.lineWidth flashLayer.lineJoin = .round flashLayer.lineCap = .round flashLayer.shadowColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor flashLayer.shadowOpacity = Float(WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowOpacity) flashLayer.shadowRadius = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowRadius flashLayer.shadowOffset = .zero flashLayer.opacity = 0 flashOverlayView.layer?.addSublayer(flashLayer) addSubview(flashOverlayView) keyboardCopyModeBadgeContainerView.translatesAutoresizingMaskIntoConstraints = false keyboardCopyModeBadgeContainerView.wantsLayer = true keyboardCopyModeBadgeContainerView.layer?.masksToBounds = false keyboardCopyModeBadgeContainerView.layer?.shadowColor = NSColor.black.cgColor keyboardCopyModeBadgeContainerView.layer?.shadowOpacity = 0.22 keyboardCopyModeBadgeContainerView.layer?.shadowRadius = 10 keyboardCopyModeBadgeContainerView.layer?.shadowOffset = CGSize(width: 0, height: 2) keyboardCopyModeBadgeView.translatesAutoresizingMaskIntoConstraints = false keyboardCopyModeBadgeView.wantsLayer = true keyboardCopyModeBadgeView.material = .hudWindow keyboardCopyModeBadgeView.blendingMode = .withinWindow keyboardCopyModeBadgeView.state = .active keyboardCopyModeBadgeView.layer?.cornerRadius = 18 keyboardCopyModeBadgeView.layer?.masksToBounds = true keyboardCopyModeBadgeView.layer?.borderWidth = 1 keyboardCopyModeBadgeView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor keyboardCopyModeBadgeView.alphaValue = 0.97 keyboardCopyModeBadgeIconView.translatesAutoresizingMaskIntoConstraints = false keyboardCopyModeBadgeIconView.symbolConfiguration = NSImage.SymbolConfiguration( pointSize: 13, weight: .regular, scale: .medium ) keyboardCopyModeBadgeIconView.image = NSImage( systemSymbolName: "keyboard.badge.ellipsis", accessibilityDescription: terminalKeyTableIndicatorAccessibilityLabel ) keyboardCopyModeBadgeIconView.contentTintColor = NSColor.secondaryLabelColor keyboardCopyModeBadgeLabel.translatesAutoresizingMaskIntoConstraints = false keyboardCopyModeBadgeLabel.textColor = NSColor.labelColor keyboardCopyModeBadgeLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) keyboardCopyModeBadgeLabel.lineBreakMode = .byTruncatingTail keyboardCopyModeBadgeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) keyboardCopyModeBadgeLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) keyboardCopyModeBadgeContainerView.addSubview(keyboardCopyModeBadgeView) keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeIconView) keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeLabel) NSLayoutConstraint.activate([ keyboardCopyModeBadgeView.topAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.topAnchor), keyboardCopyModeBadgeView.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.bottomAnchor), keyboardCopyModeBadgeView.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.leadingAnchor), keyboardCopyModeBadgeView.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.trailingAnchor), keyboardCopyModeBadgeView.widthAnchor.constraint(lessThanOrEqualToConstant: 180), keyboardCopyModeBadgeIconView.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.leadingAnchor, constant: 12), keyboardCopyModeBadgeIconView.centerYAnchor.constraint(equalTo: keyboardCopyModeBadgeView.centerYAnchor), keyboardCopyModeBadgeIconView.widthAnchor.constraint(equalToConstant: 18), keyboardCopyModeBadgeIconView.heightAnchor.constraint(equalToConstant: 18), keyboardCopyModeBadgeLabel.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeIconView.trailingAnchor, constant: 7), keyboardCopyModeBadgeLabel.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.trailingAnchor, constant: -14), keyboardCopyModeBadgeLabel.topAnchor.constraint(equalTo: keyboardCopyModeBadgeView.topAnchor, constant: 8), keyboardCopyModeBadgeLabel.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeView.bottomAnchor, constant: -8), ]) keyboardCopyModeBadgeContainerView.isHidden = true addSubview(keyboardCopyModeBadgeContainerView) NSLayoutConstraint.activate([ keyboardCopyModeBadgeContainerView.topAnchor.constraint(equalTo: topAnchor, constant: 8), keyboardCopyModeBadgeContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), ]) imageTransferIndicatorContainerView.translatesAutoresizingMaskIntoConstraints = false imageTransferIndicatorContainerView.wantsLayer = true imageTransferIndicatorContainerView.layer?.masksToBounds = false imageTransferIndicatorContainerView.layer?.shadowColor = NSColor.black.cgColor imageTransferIndicatorContainerView.layer?.shadowOpacity = 0.18 imageTransferIndicatorContainerView.layer?.shadowRadius = 8 imageTransferIndicatorContainerView.layer?.shadowOffset = CGSize(width: 0, height: 2) imageTransferIndicatorView.translatesAutoresizingMaskIntoConstraints = false imageTransferIndicatorView.wantsLayer = true imageTransferIndicatorView.material = .hudWindow imageTransferIndicatorView.blendingMode = .withinWindow imageTransferIndicatorView.state = .active imageTransferIndicatorView.layer?.cornerRadius = 16 imageTransferIndicatorView.layer?.masksToBounds = true imageTransferIndicatorView.layer?.borderWidth = 1 imageTransferIndicatorView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor imageTransferIndicatorView.alphaValue = 0.95 imageTransferIndicatorSpinner.translatesAutoresizingMaskIntoConstraints = false imageTransferIndicatorSpinner.style = .spinning imageTransferIndicatorSpinner.controlSize = .small imageTransferIndicatorSpinner.isDisplayedWhenStopped = false imageTransferCancelButton.translatesAutoresizingMaskIntoConstraints = false imageTransferCancelButton.isBordered = false imageTransferCancelButton.imagePosition = .imageOnly imageTransferCancelButton.image = NSImage( systemSymbolName: "xmark.circle.fill", accessibilityDescription: String(localized: "common.cancel", defaultValue: "Cancel") ) imageTransferCancelButton.contentTintColor = NSColor.secondaryLabelColor imageTransferCancelButton.toolTip = String(localized: "common.cancel", defaultValue: "Cancel") imageTransferCancelButton.setAccessibilityLabel( String(localized: "common.cancel", defaultValue: "Cancel") ) imageTransferCancelButton.target = self imageTransferCancelButton.action = #selector(handleImageTransferCancel) imageTransferIndicatorContainerView.addSubview(imageTransferIndicatorView) imageTransferIndicatorView.addSubview(imageTransferIndicatorSpinner) imageTransferIndicatorView.addSubview(imageTransferCancelButton) NSLayoutConstraint.activate([ imageTransferIndicatorView.topAnchor.constraint(equalTo: imageTransferIndicatorContainerView.topAnchor), imageTransferIndicatorView.bottomAnchor.constraint(equalTo: imageTransferIndicatorContainerView.bottomAnchor), imageTransferIndicatorView.leadingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.leadingAnchor), imageTransferIndicatorView.trailingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.trailingAnchor), imageTransferIndicatorSpinner.leadingAnchor.constraint(equalTo: imageTransferIndicatorView.leadingAnchor, constant: 10), imageTransferIndicatorSpinner.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor), imageTransferIndicatorSpinner.widthAnchor.constraint(equalToConstant: 14), imageTransferIndicatorSpinner.heightAnchor.constraint(equalToConstant: 14), imageTransferCancelButton.leadingAnchor.constraint(equalTo: imageTransferIndicatorSpinner.trailingAnchor, constant: 6), imageTransferCancelButton.trailingAnchor.constraint(equalTo: imageTransferIndicatorView.trailingAnchor, constant: -8), imageTransferCancelButton.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor), imageTransferCancelButton.widthAnchor.constraint(equalToConstant: 16), imageTransferCancelButton.heightAnchor.constraint(equalToConstant: 16), imageTransferIndicatorSpinner.topAnchor.constraint(equalTo: imageTransferIndicatorView.topAnchor, constant: 8), imageTransferIndicatorSpinner.bottomAnchor.constraint(equalTo: imageTransferIndicatorView.bottomAnchor, constant: -8), ]) imageTransferIndicatorContainerView.isHidden = true addSubview(imageTransferIndicatorContainerView) NSLayoutConstraint.activate([ imageTransferIndicatorContainerView.topAnchor.constraint( equalTo: keyboardCopyModeBadgeContainerView.bottomAnchor, constant: 8 ), imageTransferIndicatorContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), ]) scrollView.contentView.postsBoundsChangedNotifications = true observers.append(NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main ) { [weak self] _ in self?.handleScrollChange() }) observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.willStartLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.isLiveScrolling = true }) observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.didEndLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.isLiveScrolling = false // Final scroll position check to update userScrolledAwayFromBottom state self?.handleLiveScroll() }) observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.didLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.handleLiveScroll() }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidUpdateScrollbar, object: surfaceView, queue: .main ) { [weak self] notification in self?.handleScrollbarUpdate(notification) }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidReceiveWheelScroll, object: surfaceView, queue: .main ) { [weak self] _ in self?.pendingExplicitWheelScroll = true }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttySearchFocus, object: nil, queue: .main ) { [weak self] notification in guard let self, let surface = notification.object as? TerminalSurface, surface === self.surfaceView.terminalSurface else { return } self.searchFocusTarget = .searchField // Explicitly unfocus the terminal so the cursor stops blinking // when the search field takes over. surface.setFocus(false) }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidUpdateCellSize, object: surfaceView, queue: .main ) { [weak self] _ in self?.synchronizeScrollView() }) observers.append(NotificationCenter.default.addObserver( forName: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil, // Match AppKit's geometry change immediately so the terminal width // does not stay stuck behind a legacy scrollbar gutter. queue: nil ) { [weak self] _ in self?.handlePreferredScrollerStyleChange() }) } required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } deinit { #if DEBUG dlog( "surface.hosted.deinit surface=\(debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0) " + "hidden=\(isHidden ? 1 : 0) frame=\(String(format: "%.1fx%.1f", frame.width, frame.height))" ) #endif observers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.forEach { NotificationCenter.default.removeObserver($0) } deferredSearchOverlayMutationWorkItem?.cancel() imageTransferIndicatorShowWorkItem?.cancel() dropZoneOverlayView.removeFromSuperview() cancelFocusRequest() } override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } // Avoid stealing focus on scroll; focus is managed explicitly by the surface view. override var acceptsFirstResponder: Bool { false } override func layout() { super.layout() synchronizeGeometryAndContent() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() guard activeDropZone != nil || pendingDropZone != nil else { return } attachDropZoneOverlayIfNeeded() if let zone = activeDropZone ?? pendingDropZone { applyDropZoneOverlayFrame(dropZoneOverlayFrame(for: zone, in: bounds.size)) } } /// Reconcile AppKit geometry with ghostty surface geometry synchronously. /// Used after split topology mutations (close/split) to prevent a stale one-frame /// IOSurface size from being presented after pane expansion. @discardableResult func reconcileGeometryNow() -> Bool { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.reconcileGeometryNow() } return false } return synchronizeGeometryAndContent() } /// Request an immediate terminal redraw after geometry updates so stale IOSurface /// contents do not remain stretched during live resize churn. func refreshSurfaceNow(reason: String = "portal.refreshSurfaceNow") { surfaceView.terminalSurface?.forceRefresh(reason: reason) } @discardableResult private func synchronizeGeometryAndContent() -> Bool { CATransaction.begin() CATransaction.setDisableActions(true) defer { CATransaction.commit() } let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) _ = setFrameIfNeeded(scrollView, to: bounds) let targetSize = scrollView.bounds.size #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) #endif let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize) _ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame) let targetDocumentFrame = CGRect( origin: documentView.frame.origin, size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height) ) _ = setFrameIfNeeded(documentView, to: targetDocumentFrame) _ = setFrameIfNeeded(inactiveOverlayView, to: bounds) if let zone = activeDropZone { attachDropZoneOverlayIfNeeded() _ = setFrameIfNeeded( dropZoneOverlayView, to: dropZoneOverlayFrame(for: zone, in: bounds.size) ) } if let pending = pendingDropZone, bounds.width > 2, bounds.height > 2 { pendingDropZone = nil #if DEBUG let frame = dropZoneOverlayFrame(for: pending, in: bounds.size) logDropZoneOverlay(event: "flushPending", zone: pending, frame: frame) #endif // Reuse the normal show/update path so deferred overlays get the // same initial animation as direct drop-zone activation. setDropZoneOverlay(zone: pending) } _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) _ = setFrameIfNeeded(flashOverlayView, to: bounds) if let overlay = searchOverlayHostingView { _ = setFrameIfNeeded(overlay, to: bounds) } // NSScrollView can defer clip-view/content-size updates until its own layout pass, // which makes interactive width changes arrive a queue turn late on Sequoia. scrollView.layoutSubtreeIfNeeded() updateNotificationRingPath() updateFlashPath(style: lastFlashStyle) updateFlashAppearance(style: lastFlashStyle) synchronizeScrollView() synchronizeSurfaceView() let didCoreSurfaceChange = synchronizeCoreSurface() return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange } @discardableResult private func setFrameIfNeeded(_ view: NSView, to frame: CGRect) -> Bool { guard !Self.rectApproximatelyEqual(view.frame, frame) else { return false } view.frame = frame return true } private func sizeApproximatelyEqual(_ lhs: CGSize, _ rhs: CGSize, epsilon: CGFloat = 0.0001) -> Bool { abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon } private func pointApproximatelyEqual(_ lhs: CGPoint, _ rhs: CGPoint, epsilon: CGFloat = 0.5) -> Bool { abs(lhs.x - rhs.x) <= epsilon && abs(lhs.y - rhs.y) <= epsilon } private func dropZoneOverlayContainerView() -> NSView { superview ?? self } private func attachDropZoneOverlayIfNeeded() { // Keep the hover indicator outside the hosted terminal subtree so it stays purely additive // and cannot invalidate the scroll/surface layout that Ghostty renders into. let container = dropZoneOverlayContainerView() if dropZoneOverlayView.superview !== container { dropZoneOverlayView.removeFromSuperview() if container === self { addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) } else { container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: self) } #if DEBUG logDropZoneOverlay(event: "attach", zone: activeDropZone ?? pendingDropZone, frame: dropZoneOverlayView.frame) #endif return } guard container !== self else { return } guard let hostedIndex = container.subviews.firstIndex(of: self), let overlayIndex = container.subviews.firstIndex(of: dropZoneOverlayView), overlayIndex <= hostedIndex else { return } container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: self) } private func applyDropZoneOverlayFrame(_ frame: CGRect) { if Self.rectApproximatelyEqual(dropZoneOverlayView.frame, frame) { return } CATransaction.begin() CATransaction.setDisableActions(true) dropZoneOverlayView.frame = frame CATransaction.commit() } #if DEBUG private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { switch eventType { case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true default: return false } } private func hasActiveDragLoggingContext() -> Bool { let pasteboardTypes = NSPasteboard(name: .drag).types let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true let eventType = NSApp.currentEvent?.type return activeDropZone != nil || pendingDropZone != nil || ((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType)) } private func logDragGeometryChange(event: String, old: CGPoint, new: CGPoint) { guard hasActiveDragLoggingContext() else { return } let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil" let signature = "\(event)|\(surface)|\(String(format: "%.1f,%.1f", old.x, old.y))|" + "\(String(format: "%.1f,%.1f", new.x, new.y))|\(overlaySuperviewClass)|\(dropZoneOverlayView.isHidden ? 1 : 0)" guard lastDragGeometryLogSignature != signature else { return } lastDragGeometryLogSignature = signature dlog( "terminal.dragGeometry event=\(event) surface=\(surface) " + "old=\(String(format: "%.1f,%.1f", old.x, old.y)) " + "new=\(String(format: "%.1f,%.1f", new.x, new.y)) " + "overlaySuper=\(overlaySuperviewClass) " + "overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " + "overlayHidden=\(dropZoneOverlayView.isHidden ? 1 : 0)" ) } private func logLayoutDuringActiveDrag(targetSize: CGSize) { let pasteboardTypes = NSPasteboard(name: .drag).types let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true let eventType = NSApp.currentEvent?.type let hasActiveDrag = activeDropZone != nil || pendingDropZone != nil || ((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType)) guard hasActiveDrag else { return } dragLayoutLogSequence &+= 1 let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let activeZone = activeDropZone.map { String(describing: $0) } ?? "none" let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none" let event = eventType.map { String(describing: $0) } ?? "nil" let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil" dlog( "terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " + "activeZone=\(activeZone) pendingZone=\(pendingZone) " + "hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " + "event=\(event) inWindow=\(window != nil ? 1 : 0) " + "overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " + "scrollOrigin=\(String(format: "%.1f,%.1f", scrollView.contentView.bounds.origin.x, scrollView.contentView.bounds.origin.y)) " + "surfaceOrigin=\(String(format: "%.1f,%.1f", surfaceView.frame.origin.x, surfaceView.frame.origin.y)) " + "bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "target=\(String(format: "%.1fx%.1f", targetSize.width, targetSize.height))" ) } #endif override func viewDidMoveToWindow() { super.viewDidMoveToWindow() windowObservers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.removeAll() guard let window else { return } windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main ) { [weak self] _ in guard let self else { return } let searchActive = self.surfaceView.terminalSurface?.searchState != nil #if DEBUG dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") #endif self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey") }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, object: window, queue: .main ) { [weak self] _ in guard let self, let window = self.window else { return } let searchActive = self.surfaceView.terminalSurface?.searchState != nil // 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) { #if DEBUG dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder") #endif window.makeFirstResponder(nil) } else { #if DEBUG dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)") #endif } }) if window.isKeyWindow { scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow") } } func attachSurface(_ terminalSurface: TerminalSurface) { surfaceView.attachSurface(terminalSurface) } func setFocusHandler(_ handler: (() -> Void)?) { guard let handler else { surfaceView.onFocus = nil return } surfaceView.onFocus = { [weak self] in // When the terminal surface gains focus (click, tab, etc.), update the // search focus target so window reactivation restores terminal focus. if self?.surfaceView.terminalSurface?.searchState != nil { self?.searchFocusTarget = .terminal } handler() } } func beginFindEscapeSuppression() { surfaceView.beginFindEscapeSuppression() } func setTriggerFlashHandler(_ handler: (() -> Void)?) { surfaceView.onTriggerFlash = handler } func setBackgroundColor(_ color: NSColor) { guard let layer = backgroundView.layer else { return } CATransaction.begin() CATransaction.setDisableActions(true) layer.backgroundColor = color.cgColor layer.isOpaque = color.alphaComponent >= 1.0 CATransaction.commit() } func setInactiveOverlay(color: NSColor, opacity: CGFloat, visible: Bool) { let clampedOpacity = max(0, min(1, opacity)) CATransaction.begin() CATransaction.setDisableActions(true) inactiveOverlayView.layer?.backgroundColor = color.withAlphaComponent(clampedOpacity).cgColor inactiveOverlayView.isHidden = !(visible && clampedOpacity > 0.0001) CATransaction.commit() } func setNotificationRing(visible: Bool) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setNotificationRing(visible: visible) } return } let targetHidden = !visible let targetOpacity: Float = visible ? 1 : 0 guard notificationRingOverlayView.isHidden != targetHidden || notificationRingLayer.opacity != targetOpacity else { return } CATransaction.begin() CATransaction.setDisableActions(true) notificationRingOverlayView.isHidden = targetHidden notificationRingLayer.opacity = targetOpacity CATransaction.commit() } private func cancelDeferredSearchOverlayMutation() { deferredSearchOverlayMutationWorkItem?.cancel() deferredSearchOverlayMutationWorkItem = nil } private func scheduleDeferredSearchOverlayMutation( generation: UInt64, _ mutation: @escaping () -> Void ) { cancelDeferredSearchOverlayMutation() let work = DispatchWorkItem { [weak self] in guard let self else { return } guard self.searchOverlayMutationGeneration == generation else { return } self.deferredSearchOverlayMutationWorkItem = nil mutation() } deferredSearchOverlayMutationWorkItem = work DispatchQueue.main.async(execute: work) } private func cancelImageTransferIndicatorShow() { imageTransferIndicatorShowWorkItem?.cancel() imageTransferIndicatorShowWorkItem = nil } private func updateImageTransferIndicatorZOrder(relativeTo overlay: NSView?) { guard !imageTransferIndicatorContainerView.isHidden else { return } if let overlay, overlay.superview === self { addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: overlay) return } if keyboardCopyModeBadgeContainerView.superview === self, !keyboardCopyModeBadgeContainerView.isHidden { addSubview( imageTransferIndicatorContainerView, positioned: .above, relativeTo: keyboardCopyModeBadgeContainerView ) return } addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: nil) } private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) { guard !keyboardCopyModeBadgeContainerView.isHidden else { return } if let overlay, overlay.superview === self { addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) } else { addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil) } updateImageTransferIndicatorZOrder(relativeTo: overlay) } @objc private func handleImageTransferCancel() { guard let operation = activeImageTransferOperation else { return } let onCancel = activeImageTransferCancelHandler guard operation.cancel() else { return } endImageTransferIndicator(for: operation) onCancel?() } func beginImageTransferIndicator( for operation: TerminalImageTransferOperation, onCancel: @escaping () -> Void ) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.beginImageTransferIndicator(for: operation, onCancel: onCancel) } return } cancelImageTransferIndicatorShow() activeImageTransferOperation = operation activeImageTransferCancelHandler = onCancel imageTransferIndicatorSpinner.stopAnimation(nil) imageTransferIndicatorContainerView.isHidden = true let work = DispatchWorkItem { [weak self] in guard let self else { return } guard self.activeImageTransferOperation === operation else { return } guard !operation.isCancelled else { return } self.imageTransferIndicatorShowWorkItem = nil self.imageTransferIndicatorSpinner.startAnimation(nil) self.imageTransferIndicatorContainerView.isHidden = false self.updateImageTransferIndicatorZOrder(relativeTo: self.searchOverlayHostingView) } imageTransferIndicatorShowWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: work) } func endImageTransferIndicator(for operation: TerminalImageTransferOperation?) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.endImageTransferIndicator(for: operation) } return } if let operation, activeImageTransferOperation !== operation { return } cancelImageTransferIndicatorShow() activeImageTransferOperation = nil activeImageTransferCancelHandler = nil imageTransferIndicatorSpinner.stopAnimation(nil) imageTransferIndicatorContainerView.isHidden = true } private func makeSearchOverlayRootView( terminalSurface: TerminalSurface, searchState: TerminalSurface.SearchState ) -> SurfaceSearchOverlay { SurfaceSearchOverlay( tabId: terminalSurface.tabId, surfaceId: terminalSurface.id, searchState: searchState, canApplyFocusRequest: { [weak self] in self?.canApplyMountedSearchFieldFocusRequest() ?? false }, onMoveFocusToTerminal: { [weak self] in self?.searchFocusTarget = .terminal self?.moveFocus() }, onNavigateSearch: { [weak terminalSurface] action in _ = terminalSurface?.performBindingAction(action) }, onFieldDidFocus: { [weak self, weak terminalSurface] in self?.searchFocusTarget = .searchField terminalSurface?.setFocus(false) }, onClose: { [weak self, weak terminalSurface] in terminalSurface?.searchState = nil self?.moveFocus() } ) } private func findEditableSearchField(in view: NSView?) -> NSTextField? { guard let view else { return nil } if let field = view as? NSTextField, field.isEditable { return field } for subview in view.subviews { if let field = findEditableSearchField(in: subview) { return field } } return nil } private func canApplyMountedSearchFieldFocusRequest() -> Bool { guard let terminalSurface = surfaceView.terminalSurface, let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: terminalSurface.tabId), manager.selectedTabId == terminalSurface.tabId, let workspace = manager.tabs.first(where: { $0.id == terminalSurface.tabId }) else { return false } return workspace.focusedPanelId == terminalSurface.id } private func requestMountedSearchFieldFocus( generation: UInt64, force: Bool, attemptsRemaining: Int = 4 ) { guard searchOverlayMutationGeneration == generation else { return } guard force || searchFocusTarget == .searchField else { return } guard canApplyMountedSearchFieldFocusRequest() else { return } guard let overlay = searchOverlayHostingView, overlay.superview === self, let window, window.isKeyWindow else { return } guard let field = findEditableSearchField(in: overlay) else { guard attemptsRemaining > 0 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in self?.requestMountedSearchFieldFocus( generation: generation, force: force, attemptsRemaining: attemptsRemaining - 1 ) } return } let firstResponder = window.firstResponder let alreadyFocused = firstResponder === field || field.currentEditor() != nil || ((firstResponder as? NSTextView)?.delegate as? NSTextField) === field guard !alreadyFocused else { return } surfaceView.terminalSurface?.setFocus(false) let result = window.makeFirstResponder(field) #if DEBUG dlog( "find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " + "firstResponder=\(String(describing: window.firstResponder))" ) #endif guard !result, attemptsRemaining > 0 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in self?.requestMountedSearchFieldFocus( generation: generation, force: force, attemptsRemaining: attemptsRemaining - 1 ) } } func setSearchOverlay(searchState: TerminalSurface.SearchState?) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setSearchOverlay(searchState: searchState) } return } searchOverlayMutationGeneration &+= 1 let mutationGeneration = searchOverlayMutationGeneration // Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view. // SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces. guard let terminalSurface = surfaceView.terminalSurface, let searchState else { let hadOverlay = searchOverlayHostingView != nil lastSearchOverlayStateID = nil searchFocusTarget = .searchField guard hadOverlay else { cancelDeferredSearchOverlayMutation() return } #if DEBUG dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") #endif scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self] in self?.searchOverlayHostingView?.removeFromSuperview() self?.searchOverlayHostingView = nil } return } let searchStateID = ObjectIdentifier(searchState) if let overlay = searchOverlayHostingView, lastSearchOverlayStateID == searchStateID, overlay.superview === self { cancelDeferredSearchOverlayMutation() _ = setFrameIfNeeded(overlay, to: bounds) updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) return } let hadOverlay = searchOverlayHostingView != nil #if DEBUG dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") #endif let rootView = makeSearchOverlayRootView( terminalSurface: terminalSurface, searchState: searchState ) if let overlay = searchOverlayHostingView { overlay.rootView = rootView lastSearchOverlayStateID = searchStateID if overlay.superview !== self { scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in guard let self, let overlay else { return } overlay.removeFromSuperview() overlay.frame = self.bounds overlay.autoresizingMask = [.width, .height] self.addSubview(overlay) self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) self.requestMountedSearchFieldFocus( generation: mutationGeneration, force: false ) } return } cancelDeferredSearchOverlayMutation() _ = setFrameIfNeeded(overlay, to: bounds) updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) return } searchFocusTarget = .searchField let overlay = NSHostingView(rootView: rootView) overlay.frame = bounds overlay.autoresizingMask = [.width, .height] searchOverlayHostingView = overlay lastSearchOverlayStateID = searchStateID scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in guard let self, let overlay else { return } guard self.searchOverlayHostingView === overlay else { return } overlay.removeFromSuperview() overlay.frame = self.bounds overlay.autoresizingMask = [.width, .height] self.addSubview(overlay) self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) self.requestMountedSearchFieldFocus( generation: mutationGeneration, force: true ) } } func syncKeyStateIndicator(text: String?) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.syncKeyStateIndicator(text: text) } return } if let text, !text.isEmpty { keyboardCopyModeBadgeLabel.stringValue = text keyboardCopyModeBadgeIconView.setAccessibilityLabel(text) let needsReorder = keyboardCopyModeBadgeContainerView.isHidden || keyboardCopyModeBadgeContainerView.superview !== self || subviews.last !== keyboardCopyModeBadgeContainerView keyboardCopyModeBadgeContainerView.isHidden = false if needsReorder { updateKeyboardCopyModeBadgeZOrder(relativeTo: searchOverlayHostingView) } return } keyboardCopyModeBadgeIconView.setAccessibilityLabel(terminalKeyTableIndicatorAccessibilityLabel) keyboardCopyModeBadgeContainerView.isHidden = true } private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { let padding: CGFloat = 4 let localFrame: CGRect switch zone { case .center: localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2) case .left: localFrame = CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) case .right: localFrame = CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) case .top: localFrame = CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding) case .bottom: localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding) } let container = dropZoneOverlayView.superview ?? superview guard let container, container !== self else { return localFrame } return container.convert(localFrame, from: self) } private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, 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 setDropZoneOverlay(zone: DropZone?) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setDropZoneOverlay(zone: zone) } return } if let zone, (bounds.width <= 2 || bounds.height <= 2) { pendingDropZone = zone #if DEBUG logDropZoneOverlay(event: "deferZeroBounds", zone: zone, frame: nil) #endif return } let previousZone = activeDropZone activeDropZone = zone pendingDropZone = nil if let zone { #if DEBUG if window == nil { logDropZoneOverlay(event: "showNoWindow", zone: zone, frame: nil) } #endif attachDropZoneOverlayIfNeeded() let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size) let previousFrame = dropZoneOverlayView.frame let isSameFrame = Self.rectApproximatelyEqual(previousFrame, targetFrame) let needsFrameUpdate = !isSameFrame let zoneChanged = previousZone != zone if !dropZoneOverlayView.isHidden && !needsFrameUpdate && !zoneChanged { return } dropZoneOverlayAnimationGeneration &+= 1 dropZoneOverlayView.layer?.removeAllAnimations() if dropZoneOverlayView.isHidden { applyDropZoneOverlayFrame(targetFrame) dropZoneOverlayView.alphaValue = 0 dropZoneOverlayView.isHidden = false #if DEBUG recordDropOverlayShowAnimation() #endif #if DEBUG logDropZoneOverlay(event: "show", zone: zone, frame: targetFrame) #endif NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) dropZoneOverlayView.animator().alphaValue = 1 } completionHandler: { [weak self] in #if DEBUG guard let self else { return } guard self.activeDropZone == zone else { return } self.logDropZoneOverlay(event: "showComplete", zone: zone, frame: targetFrame) #endif } return } #if DEBUG if needsFrameUpdate || zoneChanged { logDropZoneOverlay(event: "update", zone: zone, frame: targetFrame) } #endif NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) if needsFrameUpdate { dropZoneOverlayView.animator().frame = targetFrame } if dropZoneOverlayView.alphaValue < 1 { dropZoneOverlayView.animator().alphaValue = 1 } } } else { guard !dropZoneOverlayView.isHidden else { return } dropZoneOverlayAnimationGeneration &+= 1 let animationGeneration = dropZoneOverlayAnimationGeneration dropZoneOverlayView.layer?.removeAllAnimations() #if DEBUG logDropZoneOverlay(event: "hide", zone: nil, frame: nil) #endif NSAnimationContext.runAnimationGroup { context in context.duration = 0.14 context.timingFunction = CAMediaTimingFunction(name: .easeOut) dropZoneOverlayView.animator().alphaValue = 0 } completionHandler: { [weak self] in guard let self else { return } guard self.dropZoneOverlayAnimationGeneration == animationGeneration else { return } guard self.activeDropZone == nil else { return } self.dropZoneOverlayView.isHidden = true self.dropZoneOverlayView.alphaValue = 1 #if DEBUG self.logDropZoneOverlay(event: "hideComplete", zone: nil, frame: nil) #endif } } } #if DEBUG private func logDropZoneOverlay(event: String, zone: DropZone?, frame: CGRect?) { let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let zoneText = zone.map { String(describing: $0) } ?? "none" let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height) let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil" let scrollOriginText = String( format: "%.1f,%.1f", scrollView.contentView.bounds.origin.x, scrollView.contentView.bounds.origin.y ) let surfaceOriginText = String( format: "%.1f,%.1f", surfaceView.frame.origin.x, surfaceView.frame.origin.y ) let frameText: String if let frame { frameText = String( format: "%.1f,%.1f %.1fx%.1f", frame.origin.x, frame.origin.y, frame.width, frame.height ) } else { frameText = "-" } let signature = "\(event)|\(surface)|\(zoneText)|\(boundsText)|\(frameText)|\(overlaySuperviewClass)|" + "\(scrollOriginText)|\(surfaceOriginText)|\(dropZoneOverlayView.isHidden ? 1 : 0)" guard lastDropZoneOverlayLogSignature != signature else { return } lastDropZoneOverlayLogSignature = signature dlog( "terminal.dropOverlay event=\(event) surface=\(surface) zone=\(zoneText) " + "hidden=\(dropZoneOverlayView.isHidden ? 1 : 0) bounds=\(boundsText) frame=\(frameText) " + "overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " + "scrollOrigin=\(scrollOriginText) surfaceOrigin=\(surfaceOriginText)" ) } #endif func triggerFlash(style: FlashStyle = .navigation) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.lastFlashStyle = style #if DEBUG if let surfaceId = self.surfaceView.terminalSurface?.id { Self.recordFlash(for: surfaceId) } #endif self.updateFlashPath(style: style) self.updateFlashAppearance(style: style) self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") animation.values = FocusFlashPattern.values.map { NSNumber(value: $0) } animation.keyTimes = FocusFlashPattern.keyTimes.map { NSNumber(value: $0) } animation.duration = FocusFlashPattern.duration animation.timingFunctions = FocusFlashPattern.curves.map { curve in switch curve { case .easeIn: return CAMediaTimingFunction(name: .easeIn) case .easeOut: return CAMediaTimingFunction(name: .easeOut) } } self.flashLayer.add(animation, forKey: "cmux.flash") } } func setVisibleInUI(_ visible: Bool) { let wasVisible = surfaceView.isVisibleInUI surfaceView.setVisibleInUI(visible) isHidden = !visible if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible { lastRequestedPortalOcclusionVisible = visible surfaceView.terminalSurface?.setOcclusion(visible) } #if DEBUG if wasVisible != visible { let transition = "\(wasVisible ? 1 : 0)->\(visible ? 1 : 0)" let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.visible", suffix: suffix ) } #endif if wasVisible != visible { NotificationCenter.default.post( name: .terminalPortalVisibilityDidChange, object: self, userInfo: [ GhosttyNotificationKey.surfaceId: surfaceView.terminalSurface?.id as Any, GhosttyNotificationKey.tabId: surfaceView.tabId as Any ] ) } if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { window.makeFirstResponder(nil) } } else { scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") } } var debugPortalVisibleInUI: Bool { surfaceView.isVisibleInUI } var debugPortalActive: Bool { isActive } var debugPortalFrameInWindow: CGRect { guard window != nil else { return .zero } return convert(bounds, to: nil) } func setActive(_ active: Bool) { let wasActive = isActive isActive = active #if DEBUG if wasActive != active { let transition = "\(wasActive ? 1 : 0)->\(active ? 1 : 0)" let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.active", suffix: suffix ) } #endif if active { scheduleAutomaticFirstResponderApply(reason: "setActive") } else { resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } } #if DEBUG private func debugLogWorkspaceSwitchTiming(event: String, suffix: String) { guard let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() else { dlog("\(event) id=none \(suffix)") return } let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") } private func debugFirstResponderLabel() -> String { guard let window, let firstResponder = window.firstResponder else { return "nil" } if let view = firstResponder as? NSView { if view === surfaceView { return "surfaceView" } if view.isDescendant(of: surfaceView) { return "surfaceDescendant" } return String(describing: type(of: view)) } return String(describing: type(of: firstResponder)) } private func debugVisibilityStateSuffix(transition: String) -> String { let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0 let inWindow = window != nil ? 1 : 0 let hasSuperview = superview != nil ? 1 : 0 let hostHidden = isHidden ? 1 : 0 let surfaceHidden = surfaceView.isHidden ? 1 : 0 let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height) let frameText = String(format: "%.1fx%.1f", frame.width, frame.height) let responder = debugFirstResponderLabel() return "surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " + "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " + "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " + "bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)" } #endif func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { #if DEBUG let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let searchActive = self.surfaceView.terminalSurface?.searchState != nil dlog( "find.moveFocus to=\(surfaceShort) " + "from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "searchState=\(searchActive ? "active" : "nil") " + "delayMs=\(Int((delay ?? 0) * 1000))" ) #endif let work = { [weak self] in guard let self else { return } guard let window = self.window else { return } #if DEBUG let before = String(describing: window.firstResponder) #endif if let previous, previous !== self { _ = previous.surfaceView.resignFirstResponder() } let result = window.makeFirstResponder(self.surfaceView) #if DEBUG dlog( "find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))" ) #endif } if let delay, delay > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { work() } } else { if Thread.isMainThread { work() } else { DispatchQueue.main.async { work() } } } } #if DEBUG @discardableResult func debugSimulateFileDrop(paths: [String]) -> Bool { surfaceView.debugSimulateFileDrop(paths: paths) } func debugPendingSurfaceSize() -> CGSize? { surfaceView.debugPendingSurfaceSize() } func debugRegisteredDropTypes() -> [String] { surfaceView.debugRegisteredDropTypes() } func debugInactiveOverlayState() -> (isHidden: Bool, alpha: CGFloat) { ( inactiveOverlayView.isHidden, inactiveOverlayView.layer?.backgroundColor.flatMap { NSColor(cgColor: $0)?.alphaComponent } ?? 0 ) } func debugNotificationRingState() -> (isHidden: Bool, opacity: Float) { ( notificationRingOverlayView.isHidden, notificationRingLayer.opacity ) } struct DebugDropZoneOverlayState { let isHidden: Bool let frame: CGRect let isAttachedToHostedView: Bool let isAttachedToParentContainer: Bool } func debugDropZoneOverlayState() -> DebugDropZoneOverlayState { DebugDropZoneOverlayState( isHidden: dropZoneOverlayView.isHidden, frame: dropZoneOverlayView.frame, isAttachedToHostedView: dropZoneOverlayView.superview === self, isAttachedToParentContainer: dropZoneOverlayView.superview === superview ) } func debugHasSearchOverlay() -> Bool { guard let overlay = searchOverlayHostingView else { return false } return overlay.superview === self && !overlay.isHidden } func debugHasKeyboardCopyModeIndicator() -> Bool { keyboardCopyModeBadgeContainerView.superview === self && !keyboardCopyModeBadgeContainerView.isHidden } #endif fileprivate var hasActiveDropZoneOverlay: Bool { activeDropZone != nil || pendingDropZone != nil } /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. func handleDroppedURLs(_ urls: [URL]) -> Bool { #if DEBUG dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))") #endif return surfaceView.handleDroppedFileURLs(urls) } func terminalViewForDrop(at point: NSPoint) -> GhosttyNSView? { guard bounds.contains(point), !isHidden else { return nil } return surfaceView } #if DEBUG /// Sends a synthetic key press/release pair directly to the surface view. /// This exercises the same key path as real keyboard input (ghostty_surface_key), /// unlike sendText, which bypasses key translation. @discardableResult func debugSendSyntheticKeyPressAndReleaseForUITest( characters: String, charactersIgnoringModifiers: String, keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags = [] ) -> Bool { guard let window else { return false } window.makeFirstResponder(surfaceView) let timestamp = ProcessInfo.processInfo.systemUptime guard let keyDown = NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: modifierFlags, timestamp: timestamp, windowNumber: window.windowNumber, context: nil, characters: characters, charactersIgnoringModifiers: charactersIgnoringModifiers, isARepeat: false, keyCode: keyCode ) else { return false } guard let keyUp = NSEvent.keyEvent( with: .keyUp, location: .zero, modifierFlags: modifierFlags, timestamp: timestamp + 0.001, windowNumber: window.windowNumber, context: nil, characters: characters, charactersIgnoringModifiers: charactersIgnoringModifiers, isARepeat: false, keyCode: keyCode ) else { return false } surfaceView.keyDown(with: keyDown) surfaceView.keyUp(with: keyUp) return true } /// Sends a synthetic Ctrl+D key press directly to the surface view. /// This exercises the same key path as real keyboard input (ghostty_surface_key), /// unlike `sendText`, which bypasses key translation. @discardableResult func sendSyntheticCtrlDForUITest(modifierFlags: NSEvent.ModifierFlags = [.control]) -> Bool { debugSendSyntheticKeyPressAndReleaseForUITest( characters: "\u{04}", charactersIgnoringModifiers: "d", keyCode: 2, modifierFlags: modifierFlags ) } #endif func ensureFocus(for tabId: UUID, surfaceId: UUID) { let hasUsablePortalGeometry: Bool = { let size = bounds.size return size.width > 1 && size.height > 1 }() let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor guard isActive else { return } guard let window else { return } guard surfaceView.isVisibleInUI else { #if DEBUG dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=not_visible" ) #endif scheduleAutomaticFirstResponderApply(reason: "ensureFocus.notVisible") return } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" ) #endif scheduleAutomaticFirstResponderApply(reason: "ensureFocus.hiddenOrTiny") return } guard let delegate = AppDelegate.shared, let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, tabManager.selectedTabId == tabId else { scheduleAutomaticFirstResponderApply(reason: "ensureFocus.inactiveTab") return } guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) }) else { scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane") return } guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, tab.bonsplitController.focusedPaneId == paneId else { scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane") return } // Search focus restoration — only after confirming this is the active tab/pane. if surfaceView.terminalSurface?.searchState != nil { #if DEBUG dlog( "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + "firstResponder=\(String(describing: window.firstResponder))" ) #endif restoreSearchFocus(window: window) return } if let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder") return } if !window.isKeyWindow { guard shouldAllowEnsureFocusWindowActivation( activeTabManager: delegate.tabManager, targetTabManager: tabManager, keyWindow: NSApp.keyWindow, mainWindow: NSApp.mainWindow, targetWindow: window ) else { return } window.makeKeyAndOrderFront(nil) } let result = window.makeFirstResponder(surfaceView) #if DEBUG dlog( "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) #endif if !isSurfaceViewFirstResponder() { scheduleAutomaticFirstResponderApply(reason: "ensureFocus.afterMakeFirstResponder") } else { reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder") } } private func matchesCurrentTerminalFocusTarget(tabId: UUID, surfaceId: UUID) -> Bool { guard let delegate = AppDelegate.shared, let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, tabManager.selectedTabId == tabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }), let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) }) else { return false } return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface && tab.bonsplitController.focusedPaneId == paneId } /// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during /// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles. func suppressReparentFocus() { surfaceView.suppressingReparentFocus = true } func clearSuppressReparentFocus() { surfaceView.suppressingReparentFocus = false let hasUsablePortalGeometry: Bool = { let size = bounds.size return size.width > 1 && size.height > 1 }() let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" guard surfaceView.desiredFocus else { return } guard isSurfaceViewFirstResponder() else { return } guard isActive else { return } guard surfaceView.isVisibleInUI else { return } guard let window, window.isKeyWindow else { return } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG dlog( "focus.reparent.resume.defer surface=\(surfaceShort) " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" ) #endif scheduleAutomaticFirstResponderApply(reason: "clearSuppressReparentFocus.hiddenOrTiny") return } #if DEBUG dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))") #endif reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus") } /// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder. /// This is stricter than checking `hostedView` descendants, since the scroll view can sometimes become /// first responder transiently while focus is being applied. func isSurfaceViewFirstResponder() -> Bool { guard let window, let fr = window.firstResponder as? NSView else { return false } return fr === surfaceView || fr.isDescendant(of: surfaceView) } private func scheduleAutomaticFirstResponderApply(reason: String) { guard !pendingAutomaticFirstResponderApply else { return } pendingAutomaticFirstResponderApply = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.pendingAutomaticFirstResponderApply = false #if DEBUG let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)") #endif self.applyFirstResponderIfNeeded() } } private func reassertTerminalSurfaceFocus(reason: String) { guard let terminalSurface = surfaceView.terminalSurface else { return } if terminalSurface.surface == nil { terminalSurface.requestBackgroundSurfaceStartIfNeeded() } #if DEBUG dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") #endif terminalSurface.setFocus(true) refreshSurfaceAfterFocusIfNeeded(reason: reason) } private func refreshSurfaceAfterFocusIfNeeded(reason: String) { guard let terminalSurface = surfaceView.terminalSurface, isActive, let window, window.isKeyWindow, surfaceView.isVisibleInUI else { return } let now = CACurrentMediaTime() if now - lastFocusRefreshAt < 0.05 { return } lastFocusRefreshAt = now #if DEBUG dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") #endif terminalSurface.forceRefresh(reason: "focus.surface.\(reason)") } private func applyFirstResponderIfNeeded() { let hasUsablePortalGeometry: Bool = { let size = bounds.size return size.width > 1 && size.height > 1 }() let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" guard isActive else { return } guard surfaceView.isVisibleInUI else { return } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG dlog( "focus.apply.skip surface=\(surfaceShort) " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" ) #endif return } guard let window, window.isKeyWindow else { return } guard let tabId = surfaceView.tabId, let panelId = surfaceView.terminalSurface?.id, matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else { #if DEBUG dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target") #endif return } if surfaceView.terminalSurface?.searchState != nil { // Find bar is open. Restore focus based on what the user last intended. restoreSearchFocus(window: window) return } if let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder") return } // Don't steal focus from a search overlay on another surface in this window. if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) { #if DEBUG dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused") #endif return } #if DEBUG dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))") #endif window.makeFirstResponder(surfaceView) if isSurfaceViewFirstResponder() { reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder") } } /// Restore focus when window becomes key and the find bar is open. /// Respects `searchFocusTarget` so Escape-to-terminal intent is preserved across window switches. private func restoreSearchFocus(window: NSWindow) { let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" switch searchFocusTarget { case .searchField: if let firstResponder = window.firstResponder, isCurrentSurfaceSearchFieldResponder(firstResponder) { surfaceView.terminalSurface?.setFocus(false) #if DEBUG dlog( "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + "reason=alreadyFocused firstResponder=\(String(describing: firstResponder))" ) #endif return } if let firstResponder = window.firstResponder, isSearchOverlayOrDescendant(firstResponder), !isCurrentSurfaceSearchResponder(firstResponder) { surfaceView.terminalSurface?.setFocus(false) #if DEBUG dlog( "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + "reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))" ) #endif return } // Explicitly unfocus the terminal so cursor stops blinking immediately. // The notification observer also does this, but it runs async when posted from main. surfaceView.terminalSurface?.setFocus(false) // Post notification — SearchTextFieldRepresentable's Coordinator // observes it and calls makeFirstResponder on the native NSTextField. if let terminalSurface = surfaceView.terminalSurface { NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) } #if DEBUG dlog( "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " + "via=notification firstResponder=\(String(describing: window.firstResponder))" ) #endif case .terminal: let result = window.makeFirstResponder(surfaceView) #if DEBUG dlog( "find.restoreSearchFocus surface=\(surfaceShort) target=terminal " + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) #endif } } func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent { if surfaceView.terminalSurface?.searchState != nil { if let firstResponder = window?.firstResponder as? NSView, (firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) { return .surface } if let firstResponder = window?.firstResponder, isCurrentSurfaceSearchResponder(firstResponder) { return .findField } if searchFocusTarget == .searchField { return .findField } } return .surface } func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent { if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField { return .findField } return .surface } func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) { switch intent { case .surface: searchFocusTarget = .terminal case .findField: guard surfaceView.terminalSurface?.searchState != nil else { return } searchFocusTarget = .searchField } #if DEBUG dlog( "find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "target=\(intent == .findField ? "searchField" : "terminal")" ) #endif } @discardableResult func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool { switch intent { case .surface: searchFocusTarget = .terminal setActive(true) applyFirstResponderIfNeeded() return true case .findField: guard let terminalSurface = surfaceView.terminalSurface, terminalSurface.searchState != nil else { return false } searchFocusTarget = .searchField setActive(true) if let window { restoreSearchFocus(window: window) } else { terminalSurface.setFocus(false) NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) } #if DEBUG dlog( "find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " + "target=searchField firstResponder=\(String(describing: window?.firstResponder))" ) #endif return true } } func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? { if isCurrentSurfaceSearchResponder(responder) { return .findField } let resolvedResponder: NSResponder if let editor = responder as? NSTextView, editor.isFieldEditor, let editedView = editor.delegate as? NSView { resolvedResponder = editedView } else { resolvedResponder = responder } guard let view = resolvedResponder as? NSView else { return nil } if view === surfaceView || view.isDescendant(of: surfaceView) { return .surface } return nil } @discardableResult func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool { guard let firstResponder = window.firstResponder, ownedPanelFocusIntent(for: firstResponder) == intent else { return false } surfaceView.terminalSurface?.setFocus(false) resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent") #if DEBUG dlog( "focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "target=\(intent == .findField ? "searchField" : "terminal")" ) #endif return true } private func resignOwnedFirstResponderIfNeeded(reason: String) { guard let window, let firstResponder = window.firstResponder else { return } let ownsSurfaceResponder: Bool = { guard let view = firstResponder as? NSView else { return false } return view === surfaceView || view.isDescendant(of: surfaceView) }() guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return } #if DEBUG dlog( "focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=\(reason) firstResponder=\(String(describing: firstResponder))" ) #endif window.makeFirstResponder(nil) } /// Check if a responder is inside a search overlay hosting view. /// Handles the AppKit field-editor case: when an NSTextField is being edited, /// window.firstResponder is the shared NSTextView field editor, not the text field. private func isSearchOverlayOrDescendant(_ responder: NSResponder) -> Bool { // If the responder is a field editor, follow its delegate back to the owning control. if let editor = responder as? NSTextView, editor.isFieldEditor, let editedView = editor.delegate as? NSView { return isSearchOverlayOrDescendant(editedView) } guard let view = responder as? NSView else { return false } var current: NSView? = view while let v = current { if v is NSHostingView { return true } let typeName = String(describing: type(of: v)) if typeName.contains("BrowserSearchOverlay") { return true } current = v.superview } return false } private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool { let resolvedResponder: NSResponder if let editor = responder as? NSTextView, editor.isFieldEditor, let editedView = editor.delegate as? NSView { resolvedResponder = editedView } else { resolvedResponder = responder } guard let view = resolvedResponder as? NSView else { return false } return view.isDescendant(of: self) } private func isCurrentSurfaceSearchFieldResponder(_ responder: NSResponder) -> Bool { if let editor = responder as? NSTextView, editor.isFieldEditor, let editedView = editor.delegate as? NSTextField { return editedView.isDescendant(of: self) && isSearchOverlayOrDescendant(editedView) } guard let textField = responder as? NSTextField else { return false } return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField) } #if DEBUG struct DebugRenderStats { let drawCount: Int let lastDrawTime: CFTimeInterval let metalDrawableCount: Int let metalLastDrawableTime: CFTimeInterval let presentCount: Int let lastPresentTime: CFTimeInterval let layerClass: String let layerContentsKey: String let inWindow: Bool let windowIsKey: Bool let windowOcclusionVisible: Bool let appIsActive: Bool let isActive: Bool let desiredFocus: Bool let isFirstResponder: Bool } func debugRenderStats() -> DebugRenderStats { let layerClass = surfaceView.layer.map { String(describing: type(of: $0)) } ?? "nil" let (metalCount, metalLast) = (surfaceView.layer as? GhosttyMetalLayer)?.debugStats() ?? (0, 0) let (drawCount, lastDraw): (Int, CFTimeInterval) = surfaceView.terminalSurface.map { terminalSurface in Self.drawStats(for: terminalSurface.id) } ?? (0, 0) let (presentCount, lastPresent, contentsKey): (Int, CFTimeInterval, String) = surfaceView.terminalSurface.map { terminalSurface in let stats = Self.updatePresentStats(surfaceId: terminalSurface.id, layer: surfaceView.layer) return (stats.count, stats.last, stats.key) } ?? (0, 0, Self.contentsKey(for: surfaceView.layer)) let inWindow = (window != nil) let windowIsKey = window?.isKeyWindow ?? false let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false) let appIsActive = NSApp.isActive let fr = window?.firstResponder as? NSView let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false) return DebugRenderStats( drawCount: drawCount, lastDrawTime: lastDraw, metalDrawableCount: metalCount, metalLastDrawableTime: metalLast, presentCount: presentCount, lastPresentTime: lastPresent, layerClass: layerClass, layerContentsKey: contentsKey, inWindow: inWindow, windowIsKey: windowIsKey, windowOcclusionVisible: windowOcclusionVisible, appIsActive: appIsActive, isActive: isActive, desiredFocus: surfaceView.desiredFocus, isFirstResponder: isFirstResponder ) } #endif #if DEBUG struct DebugFrameSample { let sampleCount: Int let uniqueQuantized: Int let lumaStdDev: Double let modeFraction: Double let fingerprint: UInt64 let iosurfaceWidthPx: Int let iosurfaceHeightPx: Int let expectedWidthPx: Int let expectedHeightPx: Int let layerClass: String let layerContentsGravity: String let layerContentsKey: String var isProbablyBlank: Bool { (lumaStdDev < 3.5 && modeFraction > 0.985) || (uniqueQuantized <= 6 && modeFraction > 0.95) } } /// Create a CGImage from the terminal's IOSurface-backed layer contents. /// /// This avoids Screen Recording permissions (unlike CGWindowListCreateImage) and is therefore /// suitable for debug socket tests running in headless/VM contexts. func debugCopyIOSurfaceCGImage() -> CGImage? { guard let modelLayer = surfaceView.layer else { return nil } let layer = modelLayer.presentation() ?? modelLayer guard let contents = layer.contents else { return nil } let cf = contents as CFTypeRef guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { return nil } let surfaceRef = (contents as! IOSurfaceRef) let width = Int(IOSurfaceGetWidth(surfaceRef)) let height = Int(IOSurfaceGetHeight(surfaceRef)) let bytesPerRow = Int(IOSurfaceGetBytesPerRow(surfaceRef)) guard width > 0, height > 0, bytesPerRow > 0 else { return nil } IOSurfaceLock(surfaceRef, [], nil) defer { IOSurfaceUnlock(surfaceRef, [], nil) } let base = IOSurfaceGetBaseAddress(surfaceRef) let size = bytesPerRow * height let data = Data(bytes: base, count: size) guard let provider = CGDataProvider(data: data as CFData) else { return nil } let colorSpace = CGColorSpaceCreateDeviceRGB() let bitmapInfo = CGBitmapInfo.byteOrder32Little.union( CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) ) return CGImage( width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) } /// Sample the IOSurface backing the terminal layer (if any) to detect a transient blank frame /// without using screenshots/screen recording permissions. func debugSampleIOSurface(normalizedCrop: CGRect) -> DebugFrameSample? { guard let modelLayer = surfaceView.layer else { return nil } // Prefer the presentation layer to better match what the user sees on screen. let layer = modelLayer.presentation() ?? modelLayer let layerClass = String(describing: type(of: layer)) let layerContentsGravity = layer.contentsGravity.rawValue let contentsKey = Self.contentsKey(for: layer) let presentationScale = max(1.0, layer.contentsScale) let expectedWidthPx = Int((layer.bounds.width * presentationScale).rounded(.toNearestOrAwayFromZero)) let expectedHeightPx = Int((layer.bounds.height * presentationScale).rounded(.toNearestOrAwayFromZero)) // Ghostty uses a CoreAnimation layer whose `contents` is an IOSurface-backed object. // The concrete layer class is often `IOSurfaceLayer` (private), so avoid referencing it directly. guard let anySurface = layer.contents else { // Treat "no contents" as a blank frame: this is the visual regression we're guarding. return DebugFrameSample( sampleCount: 0, uniqueQuantized: 0, lumaStdDev: 0, modeFraction: 1, fingerprint: 0, iosurfaceWidthPx: 0, iosurfaceHeightPx: 0, expectedWidthPx: expectedWidthPx, expectedHeightPx: expectedHeightPx, layerClass: layerClass, layerContentsGravity: layerContentsGravity, layerContentsKey: contentsKey ) } // IOSurfaceLayer.contents is usually an IOSurface, but during mitigation we may // temporarily replace contents with a CGImage snapshot to avoid blank flashes. // Treat non-IOSurface contents as "non-blank" and avoid unsafe casts. let cf = anySurface as CFTypeRef guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { var fnv: UInt64 = 1469598103934665603 for b in contentsKey.utf8 { fnv ^= UInt64(b) fnv &*= 1099511628211 } return DebugFrameSample( sampleCount: 1, uniqueQuantized: 1, lumaStdDev: 999, modeFraction: 0, fingerprint: fnv, iosurfaceWidthPx: 0, iosurfaceHeightPx: 0, expectedWidthPx: expectedWidthPx, expectedHeightPx: expectedHeightPx, layerClass: layerClass, layerContentsGravity: layerContentsGravity, layerContentsKey: contentsKey ) } let surfaceRef = (anySurface as! IOSurfaceRef) let width = Int(IOSurfaceGetWidth(surfaceRef)) let height = Int(IOSurfaceGetHeight(surfaceRef)) if width <= 0 || height <= 0 { return nil } let cropPx = CGRect( x: max(0, min(CGFloat(width - 1), normalizedCrop.origin.x * CGFloat(width))), y: max(0, min(CGFloat(height - 1), normalizedCrop.origin.y * CGFloat(height))), width: max(1, min(CGFloat(width), normalizedCrop.width * CGFloat(width))), height: max(1, min(CGFloat(height), normalizedCrop.height * CGFloat(height))) ).integral let x0 = Int(cropPx.minX) let y0 = Int(cropPx.minY) let x1 = Int(min(CGFloat(width), cropPx.maxX)) let y1 = Int(min(CGFloat(height), cropPx.maxY)) if x1 <= x0 || y1 <= y0 { return nil } IOSurfaceLock(surfaceRef, [], nil) defer { IOSurfaceUnlock(surfaceRef, [], nil) } let base = IOSurfaceGetBaseAddress(surfaceRef) let bytesPerRow = IOSurfaceGetBytesPerRow(surfaceRef) if bytesPerRow <= 0 { return nil } // Assume 4 bytes/pixel BGRA (common for IOSurfaceLayer contents). let bytesPerPixel = 4 let step = 6 var hist = [UInt16: Int]() hist.reserveCapacity(256) var lumas = [Double]() lumas.reserveCapacity(((x1 - x0) / step) * ((y1 - y0) / step)) var count = 0 var fnv: UInt64 = 1469598103934665603 for y in stride(from: y0, to: y1, by: step) { let row = base.advanced(by: y * bytesPerRow) for x in stride(from: x0, to: x1, by: step) { let p = row.advanced(by: x * bytesPerPixel) let b = Double(p.load(fromByteOffset: 0, as: UInt8.self)) let g = Double(p.load(fromByteOffset: 1, as: UInt8.self)) let r = Double(p.load(fromByteOffset: 2, as: UInt8.self)) let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b lumas.append(luma) let rq = UInt16(UInt8(r) >> 4) let gq = UInt16(UInt8(g) >> 4) let bq = UInt16(UInt8(b) >> 4) let key = (rq << 8) | (gq << 4) | bq hist[key, default: 0] += 1 count += 1 let lq = UInt8(max(0, min(63, Int(luma / 4.0)))) fnv ^= UInt64(lq) fnv &*= 1099511628211 } } guard count > 0 else { return nil } let mean = lumas.reduce(0.0, +) / Double(lumas.count) let variance = lumas.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } / Double(lumas.count) let stddev = sqrt(variance) let modeCount = hist.values.max() ?? 0 let modeFrac = Double(modeCount) / Double(count) return DebugFrameSample( sampleCount: count, uniqueQuantized: hist.count, lumaStdDev: stddev, modeFraction: modeFrac, fingerprint: fnv, iosurfaceWidthPx: width, iosurfaceHeightPx: height, expectedWidthPx: expectedWidthPx, expectedHeightPx: expectedHeightPx, layerClass: layerClass, layerContentsGravity: layerContentsGravity, layerContentsKey: contentsKey ) } #endif func cancelFocusRequest() { // Intentionally no-op (no retry loops). } private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return } #if DEBUG logDragGeometryChange(event: "surfaceOrigin", old: surfaceView.frame.origin, new: visibleRect.origin) #endif 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. @discardableResult private func synchronizeCoreSurface() -> Bool { // Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight // over terminal columns during split churn. The width can flap by one scrollbar gutter, // which redraws the shell prompt multiple times on Cmd+D. Favor stable columns. let width = max(0, scrollView.contentSize.width) let height = surfaceView.frame.height guard width > 0, height > 0 else { return false } return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds, inset: NotificationRingMetrics.inset, radius: NotificationRingMetrics.cornerRadius ) } private func updateFlashPath(style: FlashStyle) { let inset: CGFloat let radius: CGFloat switch style { case .navigation, .notification: inset = NotificationRingMetrics.inset radius = NotificationRingMetrics.cornerRadius } updateOverlayRingPath( layer: flashLayer, bounds: flashOverlayView.bounds, inset: inset, radius: radius ) } private func updateFlashAppearance(style: FlashStyle) { let presentation = Self.flashPresentation(for: style) let strokeColor = presentation.accent.strokeColor flashLayer.strokeColor = strokeColor.cgColor flashLayer.shadowColor = strokeColor.cgColor flashLayer.shadowOpacity = Float(presentation.glowOpacity) flashLayer.shadowRadius = presentation.glowRadius } private func updateOverlayRingPath( layer: CAShapeLayer, bounds: CGRect, inset: CGFloat, radius: CGFloat ) { layer.frame = bounds guard bounds.width > inset * 2, bounds.height > inset * 2 else { layer.path = nil return } let rect = PanelOverlayRingMetrics.pathRect(in: bounds) layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) } private func synchronizeScrollView() { var didChangeGeometry = false let targetDocumentHeight = documentHeight() if abs(documentView.frame.height - targetDocumentHeight) > 0.5 { documentView.frame.size.height = targetDocumentHeight didChangeGeometry = true } if !isLiveScrolling { let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight let targetOrigin = CGPoint(x: 0, y: offsetY) // Check if we're currently at the bottom (with threshold for float drift) let currentOrigin = scrollView.contentView.bounds.origin let documentHeight = documentView.frame.height let viewportHeight = scrollView.contentView.bounds.height let distanceFromBottom = documentHeight - currentOrigin.y - viewportHeight let isAtBottom = distanceFromBottom <= Self.scrollToBottomThreshold // Update userScrolledAwayFromBottom based on current position if isAtBottom { userScrolledAwayFromBottom = false } // Passive bottom packets should not override an explicit scrollback review, // but the first scrollbar packet caused by the user's own wheel input should // still move the viewport to the requested scrollback position. let shouldAutoScroll = !userScrolledAwayFromBottom || allowExplicitScrollbarSync if shouldAutoScroll && !pointApproximatelyEqual(currentOrigin, targetOrigin) { scrollView.contentView.scroll(to: targetOrigin) didChangeGeometry = true } lastSentRow = Int(scrollbar.offset) } } allowExplicitScrollbarSync = false if didChangeGeometry { scrollView.reflectScrolledClipView(scrollView.contentView) } } private func handleScrollChange() { synchronizeSurfaceView() } private func handleLiveScroll() { let cellHeight = surfaceView.cellSize.height guard cellHeight > 0 else { return } let visibleRect = scrollView.contentView.documentVisibleRect let documentHeight = documentView.frame.height let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height // Track if user has scrolled away from bottom to review scrollback if scrollOffset > Self.scrollToBottomThreshold { userScrolledAwayFromBottom = true } else if scrollOffset <= 0 { userScrolledAwayFromBottom = false } let row = Int(scrollOffset / cellHeight) guard row != lastSentRow else { return } lastSentRow = row _ = surfaceView.performBindingAction("scroll_to_row:\(row)") } private func handleScrollbarUpdate(_ notification: Notification) { guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else { return } if pendingExplicitWheelScroll { userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total allowExplicitScrollbarSync = true pendingExplicitWheelScroll = false } surfaceView.scrollbar = scrollbar synchronizeScrollView() } private func handlePreferredScrollerStyleChange() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.handlePreferredScrollerStyleChange() } return } // Retile just the scroll view so contentSize reflects the current // scrollbar mode without perturbing viewport origin or hosted view // geometry; the broader reconcile path caused visible content glitches. scrollView.tile() _ = synchronizeCoreSurface() } private func documentHeight() -> CGFloat { let contentHeight = scrollView.contentSize.height let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { let documentGridHeight = CGFloat(scrollbar.total) * cellHeight let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight) return documentGridHeight + padding } return contentHeight } } // MARK: - NSTextInputClient extension GhosttyNSView: NSTextInputClient { fileprivate func sendTextToSurface(_ chars: String) { guard let surface = surface else { return } #if DEBUG let typingTimingStart = CmuxTypingTiming.start() #endif #if DEBUG cmuxWriteChildExitProbe( [ "probeInsertTextCharsHex": cmuxScalarHex(chars), "probeInsertTextSurfaceId": terminalSurface?.id.uuidString ?? "", ], increments: ["probeInsertTextCount": 1] ) #endif chars.withCString { ptr in var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = 0 keyEvent.mods = GHOSTTY_MODS_NONE keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = ptr keyEvent.composing = false _ = ghostty_surface_key(surface, keyEvent) } #if DEBUG CmuxTypingTiming.logDuration( path: "terminal.sendTextToSurface", startedAt: typingTimingStart, extra: "textBytes=\(chars.utf8.count)" ) #endif } func hasMarkedText() -> Bool { return markedText.length > 0 } func markedRange() -> NSRange { guard markedText.length > 0 else { return NSRange(location: NSNotFound, length: 0) } return NSRange(location: 0, length: markedText.length) } func selectedRange() -> NSRange { readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() defer { CmuxTypingTiming.logDuration( path: "terminal.setMarkedText", startedAt: typingTimingStart, extra: "markedLength=\(markedText.length)" ) } #endif switch string { case let v as NSAttributedString: markedText = NSMutableAttributedString(attributedString: v) case let v as String: markedText = NSMutableAttributedString(string: v) default: break } // If we're not in a keyDown event, sync preedit immediately. // This can happen due to external events like changing keyboard layouts // while composing. if keyTextAccumulator == nil { syncPreedit() invalidateTextInputCoordinates(selectionChanged: true) } } func unmarkText() { #if DEBUG let hadMarkedText = markedText.length > 0 let typingTimingStart = CmuxTypingTiming.start() defer { CmuxTypingTiming.logDuration( path: "terminal.unmarkText", startedAt: typingTimingStart, extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)" ) } #endif if markedText.length > 0 { markedText.mutableString.setString("") syncPreedit() invalidateTextInputCoordinates(selectionChanged: true) } } /// Sync the preedit state based on the markedText value to libghostty. /// This tells Ghostty about IME composition text so it can render the /// preedit overlay (e.g. for Korean, Japanese, Chinese input). private func syncPreedit(clearIfNeeded: Bool = true) { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() defer { CmuxTypingTiming.logDuration( path: "terminal.syncPreedit", startedAt: typingTimingStart, extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)" ) } #endif guard let surface = surface else { return } if markedText.length > 0 { let str = markedText.string let len = str.utf8CString.count if len > 0 { str.withCString { ptr in // Subtract 1 for the null terminator ghostty_surface_preedit(surface, ptr, UInt(len - 1)) } } } else if clearIfNeeded { // If we had marked text before but don't now, we're no longer // in a preedit state so we can clear it. ghostty_surface_preedit(surface, nil, 0) } } func validAttributesForMarkedText() -> [NSAttributedString.Key] { return [] } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { guard range.length > 0, let snapshot = readSelectionSnapshot() else { return nil } actualRange?.pointee = snapshot.range return NSAttributedString(string: snapshot.string) } func characterIndex(for point: NSPoint) -> Int { return selectedRange().location } func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { guard let window = self.window else { return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) } // Use Ghostty's IME point API for accurate cursor position if available. var x: Double = 0 var y: Double = 0 var w: Double = cellSize.width var h: Double = cellSize.height #if DEBUG if range.length > 0, range != selectedRange(), let snapshot = readSelectionSnapshot() { x = snapshot.topLeft.x - 2 y = snapshot.topLeft.y + 2 } else if let override = imePointOverrideForTesting { x = override.x y = override.y w = override.width h = override.height } else if let surface = surface { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } #else if range.length > 0, range != selectedRange(), let snapshot = readSelectionSnapshot() { x = snapshot.topLeft.x - 2 y = snapshot.topLeft.y + 2 } else if let surface = surface { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } #endif if range.length == 0, w > 0 { // Dictation expects a caret rect for insertion points rather than a box. w = 0 } // Ghostty coordinates are top-left origin; AppKit expects bottom-left. let viewRect = NSRect( x: x, y: frame.size.height - y, width: w, height: max(h, cellSize.height) ) let winRect = convert(viewRect, to: nil) return window.convertToScreen(winRect) } func attributedString() -> NSAttributedString { if markedText.length > 0 { return NSAttributedString(attributedString: markedText) } if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty { return NSAttributedString(string: snapshot.string) } return NSAttributedString(string: "") } func windowLevel() -> Int { Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue) } @available(macOS 14.0, *) var unionRectInVisibleSelectedRange: NSRect { firstRect(forCharacterRange: selectedRange(), actualRange: nil) } @available(macOS 14.0, *) var documentVisibleRect: NSRect { visibleDocumentRectInScreenCoordinates() } func insertText(_ string: Any, replacementRange: NSRange) { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() defer { CmuxTypingTiming.logDuration( path: "terminal.insertText", startedAt: typingTimingStart, event: NSApp.currentEvent, extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)" ) } #endif // Get the string value var chars = "" switch string { case let v as NSAttributedString: chars = v.string case let v as String: chars = v default: return } // Clear marked text since we're inserting unmarkText() // Some IME/input-method paths call insertText with an empty payload to // flush state. There is no terminal text to send in that case. guard !chars.isEmpty else { return } #if DEBUG if NSApp.currentEvent == nil { dlog("ime.insertText.noEvent len=\(chars.count)") } #endif // If we have an accumulator, we're in a keyDown event - accumulate the text if keyTextAccumulator != nil { keyTextAccumulator?.append(chars) return } // Otherwise send directly to the terminal sendTextToSurface(chars) } } // MARK: - SwiftUI Wrapper struct GhosttyTerminalView: NSViewRepresentable { @Environment(\.paneDropZone) var paneDropZone let terminalSurface: TerminalSurface let paneId: PaneID var isActive: Bool = true var isVisibleInUI: Bool = true var portalZPriority: Int = 0 var showsInactiveOverlay: Bool = false var showsUnreadNotificationRing: Bool = false var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 var searchState: TerminalSurface.SearchState? = nil var reattachToken: UInt64 = 0 var onFocus: ((UUID) -> Void)? = nil var onTriggerFlash: (() -> Void)? = nil private final class HostContainerView: NSView { private static var nextInstanceSerial: UInt64 = 0 var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? let instanceSerial: UInt64 private(set) var geometryRevision: UInt64 = 0 private var lastReportedGeometryState: GeometryState? override init(frame frameRect: NSRect) { Self.nextInstanceSerial &+= 1 instanceSerial = Self.nextInstanceSerial super.init(frame: frameRect) } required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } private struct GeometryState: Equatable { let frame: CGRect let bounds: CGRect let windowNumber: Int? let superviewID: ObjectIdentifier? } private func currentGeometryState() -> GeometryState { GeometryState( frame: frame, bounds: bounds, windowNumber: window?.windowNumber, superviewID: superview.map(ObjectIdentifier.init) ) } private func notifyGeometryChangedIfNeeded() { let state = currentGeometryState() guard state != lastReportedGeometryState else { return } lastReportedGeometryState = state geometryRevision &+= 1 onGeometryChanged?() } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() onDidMoveToWindow?() notifyGeometryChangedIfNeeded() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() notifyGeometryChangedIfNeeded() } override func layout() { super.layout() notifyGeometryChangedIfNeeded() } override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) notifyGeometryChangedIfNeeded() } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) notifyGeometryChangedIfNeeded() } } final class Coordinator { var attachGeneration: Int = 0 // Track the latest desired state so attach retries can re-apply focus after re-parenting. var desiredIsActive: Bool = true var desiredIsVisibleInUI: Bool = true var desiredShowsUnreadNotificationRing: Bool = false var desiredPortalZPriority: Int = 0 var lastBoundHostId: ObjectIdentifier? var lastPaneDropZone: DropZone? var lastSynchronizedHostGeometryRevision: UInt64 = 0 weak var hostedView: GhosttySurfaceScrollView? } func makeCoordinator() -> Coordinator { Coordinator() } static func shouldApplyImmediateHostedStateUpdate( hostedViewHasSuperview: Bool, isBoundToCurrentHost: Bool ) -> Bool { // If this update originates from a stale/replaced host while the hosted view is // already attached elsewhere, do not mutate visibility/active state here. if isBoundToCurrentHost { return true } return !hostedViewHasSuperview } static func shouldSynchronizePortalGeometryImmediately( hostInLiveResize: Bool, windowInLiveResize: Bool, interactiveGeometryResizeActive: Bool ) -> Bool { hostInLiveResize || windowInLiveResize || interactiveGeometryResizeActive } private static func synchronizePortalGeometry( for host: HostContainerView, coordinator: Coordinator ) { let geometryRevision = host.geometryRevision guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } coordinator.lastSynchronizedHostGeometryRevision = geometryRevision let window = host.window if shouldSynchronizePortalGeometryImmediately( hostInLiveResize: host.inLiveResize, windowInLiveResize: window?.inLiveResize == true, interactiveGeometryResizeActive: TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive ) { TerminalWindowPortalRegistry.synchronizeForAnchor(host) return } // Avoid synchronizing the terminal portal while AppKit is still inside // the current layout turn. Re-entrant syncs here can wedge window resize // handling and leave the app spinning on the wait cursor. guard let window else { return } TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) } func makeNSView(context: Context) -> NSView { let container = HostContainerView(frame: .zero) container.wantsLayer = false // The actual terminal surface lives in the AppKit portal layer above SwiftUI. // This empty placeholder should not be walked by the accessibility subsystem. container.setAccessibilityRole(.none) container.setAccessibilityElement(false) return container } func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator let previousDesiredIsActive = coordinator.desiredIsActive let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority let desiredStateChanged = previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredPortalZPriority != portalZPriority coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing coordinator.desiredPortalZPriority = portalZPriority coordinator.hostedView = hostedView #if DEBUG if desiredStateChanged { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + "active=\(isActive ? 1 : 0) z=\(portalZPriority) " + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } else { dlog( "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } } #endif let hostContainer = nsView as? HostContainerView let hostOwnsPortalNow = hostContainer.map { host in terminalSurface.claimPortalHost( hostId: ObjectIdentifier(host), paneId: paneId, instanceSerial: host.instanceSerial, inWindow: host.window != nil, bounds: host.bounds, reason: "update" ) } ?? true // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) if hostOwnsPortalNow { hostedView.setInactiveOverlay( color: inactiveOverlayColor, opacity: CGFloat(inactiveOverlayOpacity), visible: showsInactiveOverlay ) hostedView.setNotificationRing(visible: showsUnreadNotificationRing) hostedView.setSearchOverlay(searchState: searchState) hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText) } let portalExpectedSurfaceId = terminalSurface.id let portalExpectedGeneration = terminalSurface.portalBindingGeneration() func portalBindingStillLive() -> Bool { terminalSurface.canAcceptPortalBinding( expectedSurfaceId: portalExpectedSurfaceId, expectedGeneration: portalExpectedGeneration ) } let forwardedDropZone = isVisibleInUI ? paneDropZone : nil #if DEBUG if coordinator.lastPaneDropZone != paneDropZone { let oldZone = coordinator.lastPaneDropZone.map { String(describing: $0) } ?? "none" let newZone = paneDropZone.map { String(describing: $0) } ?? "none" dlog( "terminal.paneDropZone surface=\(terminalSurface.id.uuidString.prefix(5)) " + "old=\(oldZone) new=\(newZone) " + "active=\(isActive ? 1 : 0) visible=\(isVisibleInUI ? 1 : 0) " + "inWindow=\(hostedView.window != nil ? 1 : 0)" ) coordinator.lastPaneDropZone = paneDropZone } if paneDropZone != nil, !isVisibleInUI { dlog( "terminal.paneDropZone.suppress surface=\(terminalSurface.id.uuidString.prefix(5)) " + "requested=\(String(describing: paneDropZone!)) visible=0 active=\(isActive ? 1 : 0)" ) } #endif if hostOwnsPortalNow { hostedView.setDropZoneOverlay(zone: forwardedDropZone) } coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration if let host = hostContainer { 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 terminalSurface.claimPortalHost( hostId: ObjectIdentifier(host), paneId: paneId, instanceSerial: host.instanceSerial, inWindow: host.window != nil, bounds: host.bounds, reason: "didMoveToWindow" ) else { return } guard host.window != nil else { return } guard portalBindingStillLive() else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, zPriority: coordinator.desiredPortalZPriority, expectedSurfaceId: portalExpectedSurfaceId, expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = ObjectIdentifier(host) coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } guard terminalSurface.claimPortalHost( hostId: ObjectIdentifier(host), paneId: paneId, instanceSerial: host.instanceSerial, inWindow: host.window != nil, bounds: host.bounds, reason: "geometryChanged" ) else { return } guard portalBindingStillLive() else { return } let hostId = ObjectIdentifier(host) if host.window != nil, (coordinator.lastBoundHostId != hostId || !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) { #if DEBUG dlog( "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" ) #endif TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, zPriority: coordinator.desiredPortalZPriority, expectedSurfaceId: portalExpectedSurfaceId, expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = hostId hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } Self.synchronizePortalGeometry( for: host, coordinator: coordinator ) } if host.window != nil, hostOwnsPortalNow { let portalBindingLive = portalBindingStillLive() let hostId = ObjectIdentifier(host) let geometryRevision = host.geometryRevision let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) let shouldBindNow = coordinator.lastBoundHostId != hostId || hostedView.superview == nil || portalEntryMissing || previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority if portalBindingLive && shouldBindNow { #if DEBUG if portalEntryMissing { dlog( "ws.hostState.rebindOnUpdate surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" ) } #endif TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, zPriority: coordinator.desiredPortalZPriority, expectedSurfaceId: portalExpectedSurfaceId, expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { Self.synchronizePortalGeometry( for: host, coordinator: coordinator ) } } else if hostOwnsPortalNow, portalBindingStillLive() { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. #if DEBUG if desiredStateChanged { dlog( "ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " + "hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } #endif TerminalWindowPortalRegistry.updateEntryVisibility( for: hostedView, visibleInUI: coordinator.desiredIsVisibleInUI ) } } let hostWindowAttached = hostContainer?.window != nil let isBoundToCurrentHost = hostContainer.map { host in TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) } ?? true let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate( hostedViewHasSuperview: hostedView.superview != nil, isBoundToCurrentHost: isBoundToCurrentHost ) if portalBindingStillLive() && shouldApplyImmediateHostedState { hostedView.setVisibleInUI(isVisibleInUI) hostedView.setActive(isActive) } else { // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. // The currently bound host remains authoritative for immediate visible/active state. #if DEBUG if desiredStateChanged { dlog( "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " + "hostWindow=\(hostWindowAttached ? 1 : 0) " + "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" ) } #endif } } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachGeneration += 1 coordinator.desiredIsActive = false coordinator.desiredIsVisibleInUI = false coordinator.desiredShowsUnreadNotificationRing = false coordinator.desiredPortalZPriority = 0 coordinator.lastBoundHostId = nil let hostedView = coordinator.hostedView #if DEBUG if let hostedView { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + "surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + "inWindow=\(hostedView.window != nil ? 1 : 0)" ) } else { dlog( "ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + "inWindow=\(hostedView.window != nil ? 1 : 0)" ) } } #endif if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil host.onGeometryChanged = nil hostedView?.prepareOwnedPortalHostForTransientReattach( hostId: ObjectIdentifier(host), reason: "dismantle" ) } // SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split // tree updates. Do not drop the portal lease or force visible/active false here; that // causes avoidable blackouts when the same hosted view is rebound moments later. hostedView?.setFocusHandler(nil) hostedView?.setTriggerFlashHandler(nil) hostedView?.setDropZoneOverlay(zone: nil) coordinator.hostedView = nil nsView.subviews.forEach { $0.removeFromSuperview() } } }