cmux/Sources/GhosttyTerminalView.swift
Lawrence Chen ec10dfdaec
Add browser find focus debug logs (#1162)
* Add browser find focus debug logs

* Allow browser find bar focus in portal host

* Add split and terminal find debug logs

* Avoid stealing search focus across splits

* Generalize panel focus restore intent

* Unify split focus intent activation

* Address focus restore review feedback

* Yield inactive panel focus before restore

* Gate browser find focus retries by generation

* Avoid repeated browser focus invalidation

* Keep browser find ownership while find bar is open
2026-03-10 19:31:52 -07:00

8016 lines
324 KiB
Swift

import Foundation
import SwiftUI
import AppKit
import Metal
import QuartzCore
import Combine
import Darwin
import Sentry
import Bonsplit
import IOSurface
#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
private 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"
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
}
return pasteboard.string(forType: utf8PlainTextType)
}
static func hasString(for location: ghostty_clipboard_e) -> Bool {
guard let pasteboard = pasteboard(for: location) else { return false }
if let text = stringContents(from: pasteboard), !text.isEmpty { return true }
return clipboardHasImageOnly()
}
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 {
var result = value
for char in shellEscapeCharacters {
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
}
return result
}
private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
/// Quick check: does the clipboard have image data and no text?
static func clipboardHasImageOnly() -> Bool {
let pb = NSPasteboard.general
let types = pb.types ?? []
let hasText = types.contains(.string) || types.contains(.html)
|| types.contains(.rtf) || types.contains(.rtfd)
if hasText { return false }
return types.contains(.tiff) || types.contains(.png)
}
/// When the clipboard contains only image data (no text/HTML), saves it as
/// a temporary PNG file and returns the shell-escaped file path. Returns nil
/// if the clipboard contains text or no image.
static func saveClipboardImageIfNeeded() -> String? {
let pb = NSPasteboard.general
let types = pb.types ?? []
// If pasteboard has text/HTML, this is a normal copy.
let hasText = types.contains(.string) || types.contains(.html)
|| types.contains(.rtf) || types.contains(.rtfd)
if hasText { return nil }
// Check for image types (TIFF from screenshots, PNG from some tools).
guard types.contains(.tiff) || types.contains(.png) else { return nil }
guard let image = NSImage(pasteboard: pb),
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
guard pngData.count <= maxClipboardImageSize else {
#if DEBUG
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.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 = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png"
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
do {
try pngData.write(to: URL(fileURLWithPath: path))
} catch {
#if DEBUG
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
#endif
return nil
}
return escapeForShell(path)
}
}
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?
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(line.data(using: .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..<count {
let diag = ghostty_config_get_diagnostic(config, UInt32(i))
let msg = diag.message.flatMap { String(cString: $0) } ?? "(null)"
initLog(" [\(i)] \(msg)")
}
}
#endif
private func initializeGhostty() {
// Ensure TUI apps can use colors even if NO_COLOR is set in the launcher env.
if getenv("NO_COLOR") != nil {
unsetenv("NO_COLOR")
}
// Initialize Ghostty library first
let result = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv)
if result != GHOSTTY_SUCCESS {
print("Failed to initialize ghostty: \(result)")
return
}
// Load config
guard let primaryConfig = ghostty_config_new() else {
print("Failed to create ghostty config")
return
}
// Load default config (includes user config). If this fails hard (e.g. due to
// invalid user config), ghostty_app_new may return nil; we fall back below.
loadDefaultConfigFilesWithLegacyFallback(primaryConfig)
updateDefaultBackground(from: primaryConfig, source: "initialize.primaryConfig")
// Create runtime config with callbacks
var runtimeConfig = ghostty_runtime_config_s()
runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
runtimeConfig.supports_selection_clipboard = true
runtimeConfig.wakeup_cb = { userdata in
DispatchQueue.main.async {
GhosttyApp.shared.tick()
}
}
runtimeConfig.action_cb = { app, target, action in
return GhosttyApp.shared.handleAction(target: target, action: action)
}
runtimeConfig.read_clipboard_cb = { userdata, location, state in
// Read clipboard
guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
let surface = callbackContext.runtimeSurface else { return }
let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location)
var value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? ""
// When clipboard has only image data (e.g. screenshot), save as temp
// PNG and paste the file path so CLI tools can receive images.
if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() {
value = imagePath
}
value.withCString { ptr in
ghostty_surface_complete_clipboard_request(surface, ptr, state, false)
}
}
runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in
guard let content else { return }
guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
let surface = callbackContext.runtimeSurface else { return }
ghostty_surface_complete_clipboard_request(surface, content, state, true)
}
runtimeConfig.write_clipboard_cb = { _, location, content, len, _ in
// Write clipboard
guard let content = content, len > 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
}
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 loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) {
ghostty_config_load_default_files(config)
loadReleaseAppSupportGhosttyConfigIfNeeded(config)
loadLegacyGhosttyConfigIfNeeded(config)
ghostty_config_load_recursive_files(config)
loadCJKFontFallbackIfNeeded(config)
ghostty_config_finalize(config)
}
/// When the user has not configured `font-codepoint-map` for CJK ranges,
/// 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.
///
/// See: https://github.com/manaflow-ai/cmux/pull/1017
private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) {
if Self.userConfigContainsCJKCodepointMap() { return }
guard let mappings = Self.cjkFontMappings() else { return }
let lines = mappings.map { range, font in
"font-codepoint-map = \(range)=\(font)"
}.joined(separator: "\n")
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf")
do {
try lines.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
Self.initLog("failed to write CJK font fallback config: \(error)")
#endif
}
}
/// 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
]
/// Unicode ranges specific to Korean (Hangul).
private static let koreanRanges = [
"U+AC00-U+D7AF", // Hangul Syllables
"U+1100-U+11FF", // Hangul Jamo
]
/// 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("ko") {
font = "Apple SD Gothic Neo"
langRanges = koreanRanges
} 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
}
/// 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] = defaultCJKScanPaths()
) -> Bool {
var visited = Set<String>()
for rawPath in configPaths {
let path = NSString(string: rawPath).expandingTildeInPath
if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) {
return true
}
}
return false
}
/// Returns the default set of config paths to scan for existing
/// `font-codepoint-map` entries. Includes both the standard Ghostty
/// config locations and any app-support paths that cmux may load.
private static func defaultCJKScanPaths() -> [String] {
var paths = [
"~/.config/ghostty/config",
"~/.config/ghostty/config.ghostty",
"~/Library/Application Support/com.mitchellh.ghostty/config",
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
]
if let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first {
let releaseDir = appSupport.appendingPathComponent(releaseBundleIdentifier)
paths.append(releaseDir.appendingPathComponent("config").path)
paths.append(releaseDir.appendingPathComponent("config.ghostty").path)
if let bundleId = Bundle.main.bundleIdentifier, bundleId != releaseBundleIdentifier {
let currentDir = appSupport.appendingPathComponent(bundleId)
paths.append(currentDir.appendingPathComponent("config").path)
paths.append(currentDir.appendingPathComponent("config.ghostty").path)
}
}
return paths
}
/// Scans a single config file (and any files it includes) for
/// `font-codepoint-map` entries. Tracks visited paths to prevent
/// infinite recursion on cyclic includes.
private static func configFileContainsCodepointMap(
atPath path: String,
visited: inout Set<String>
) -> Bool {
let resolved = (path as NSString).standardizingPath
guard !visited.contains(resolved) else { return false }
visited.insert(resolved)
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return false
}
let parentDir = (resolved as NSString).deletingLastPathComponent
for line in contents.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("#") { continue }
if trimmed.hasPrefix("font-codepoint-map") {
return true
}
if trimmed.hasPrefix("config-file") {
let parts = trimmed.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
var includePath = parts[1]
.trimmingCharacters(in: .whitespaces)
// Ghostty supports optional includes with a trailing '?'
if includePath.hasSuffix("?") {
includePath.removeLast()
}
includePath = includePath
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
let expanded = NSString(string: includePath).expandingTildeInPath
let absolute = (expanded as NSString).isAbsolutePath
? expanded
: (parentDir as NSString).appendingPathComponent(expanded)
if configFileContainsCodepointMap(atPath: absolute, visited: &visited) {
return true
}
}
}
}
return false
}
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 shouldLoadReleaseAppSupportGhosttyConfig(
currentBundleIdentifier: String?,
currentConfigFileSize: Int?,
currentLegacyConfigFileSize: Int?,
releaseConfigFileSize: Int?,
releaseLegacyConfigFileSize: Int?
) -> Bool {
guard SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) else { return false }
let hasCurrentAppSupportConfig = (currentConfigFileSize ?? 0) > 0 || (currentLegacyConfigFileSize ?? 0) > 0
guard !hasCurrentAppSupportConfig else { return false }
let hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0
return hasReleaseAppSupportConfig
}
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 loadReleaseAppSupportGhosttyConfigIfNeeded(_ 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 currentAppSupportDir = appSupport.appendingPathComponent(currentBundleIdentifier, isDirectory: true)
let releaseAppSupportDir = appSupport.appendingPathComponent(Self.releaseBundleIdentifier, isDirectory: true)
let currentConfig = currentAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false)
let currentLegacyConfig = currentAppSupportDir.appendingPathComponent("config", isDirectory: false)
let releaseConfig = releaseAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false)
let releaseLegacyConfig = releaseAppSupportDir.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
}
let releaseConfigSize = fileSize(releaseConfig)
let releaseLegacyConfigSize = fileSize(releaseLegacyConfig)
guard Self.shouldLoadReleaseAppSupportGhosttyConfig(
currentBundleIdentifier: currentBundleIdentifier,
currentConfigFileSize: fileSize(currentConfig),
currentLegacyConfigFileSize: fileSize(currentLegacyConfig),
releaseConfigFileSize: releaseConfigSize,
releaseLegacyConfigFileSize: releaseLegacyConfigSize
) else { return }
if let releaseLegacyConfigSize, releaseLegacyConfigSize > 0 {
releaseLegacyConfig.path.withCString { path in
ghostty_config_load_file(config, path)
}
}
if let releaseConfigSize, releaseConfigSize > 0 {
releaseConfig.path.withCString { path in
ghostty_config_load_file(config, path)
}
}
#if DEBUG
Self.initLog(
"loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)"
)
#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
}
func tick() {
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<Int8>?
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<T>(_ 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<GhosttySurfaceCallbackContext>.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
}
let tabTitle = tabManager.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 tabManager = AppDelegate.shared?.tabManager else { return false }
return tabManager.newSplit(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.scrollbar = scrollbar
NotificationCenter.default.post(
name: .ghosttyDidUpdateScrollbar,
object: surfaceView,
userInfo: [GhosttyNotificationKey.scrollbar: 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)
)
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?.tabManager?.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 {
let tabTitle = AppDelegate.shared?.tabManager?.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
surfaceView.backgroundColor = 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=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString()) source=action.color_change.surface"
)
}
surfaceView.applySurfaceBackground()
if backgroundLogEnabled {
logBackground("OSC background change tab=\(surfaceView.tabId?.uuidString ?? "unknown") color=\(surfaceView.backgroundColor?.description ?? "nil")")
}
DispatchQueue.main.async {
surfaceView.applyWindowBackgroundIfActive()
}
}
return true
case GHOSTTY_ACTION_CONFIG_CHANGE:
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()
DispatchQueue.main.async {
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
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))")
}
}
}
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()
}
}
// 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 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: ghostty_surface_config_s?
private let workingDirectory: String?
private let 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 var pendingTextQueue: [Data] = []
private var pendingTextBytes: Int = 0
private let maxPendingTextBytes = 1_048_576
private var backgroundSurfaceStartQueued = false
private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>?
private enum PortalLifecycleState: String {
case live
case closing
case closed
}
private struct PortalHostLease {
let hostId: ObjectIdentifier
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<String, Never> 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: ghostty_surface_config_s?,
workingDirectory: String? = nil,
additionalEnvironment: [String: String] = [:]
) {
self.id = UUID()
self.tabId = tabId
self.surfaceContext = context
self.configTemplate = configTemplate
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
self.additionalEnvironment = 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)
}
func updateWorkspaceId(_ newTabId: UUID) {
tabId = newTabId
attachedView?.tabId = newTabId
surfaceView.tabId = newTabId
}
func isAttached(to view: GhosttyNSView) -> Bool {
attachedView === view && surface != nil
}
func portalBindingGeneration() -> UInt64 {
portalLifecycleGeneration
}
func portalBindingStateLabel() -> String {
portalLifecycleState.rawValue
}
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
}
private static let portalHostAreaThreshold: CGFloat = 4
private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2
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
}
func claimPortalHost(
hostId: ObjectIdentifier,
inWindow: Bool,
bounds: CGRect,
reason: String
) -> Bool {
let next = PortalHostLease(
hostId: hostId,
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)
let shouldReplace =
!currentUsable ||
(nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio))
if shouldReplace {
#if DEBUG
dlog(
"terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " +
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"replacingHost=\(current.hostId) 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) inWin=\(inWindow ? 1 : 0) " +
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"ownerHost=\(current.hostId) 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) 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) inWin=\(current.inWindow ? 1 : 0) " +
"area=\(String(format: "%.1f", current.area))"
)
#endif
}
func beginPortalCloseLifecycle(reason: String) {
guard portalLifecycleState != .closed else { return }
guard portalLifecycleState != .closing else { return }
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() {
markPortalLifecycleClosed(reason: "teardown")
let callbackContext = surfaceCallbackContext
surfaceCallbackContext = nil
let surfaceToFree = surface
surface = nil
guard let surfaceToFree else {
callbackContext?.release()
return
}
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"
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(line.data(using: .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(line.data(using: .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 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) {
#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)
var surfaceConfig = configTemplate ?? ghostty_surface_config_new()
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<CChar>, UnsafeMutablePointer<CChar>)] = []
defer {
for (key, value) in envStorage {
free(key)
free(value)
}
}
var env: [String: String] = [:]
if surfaceConfig.env_var_count > 0, let existingEnv = surfaceConfig.env_vars {
let count = Int(surfaceConfig.env_var_count)
if count > 0 {
for i in 0..<count {
let item = existingEnv[i]
if let key = String(cString: item.key, encoding: .utf8),
let value = String(cString: item.value, encoding: .utf8) {
env[key] = value
}
}
}
}
env["CMUX_SURFACE_ID"] = id.uuidString
env["CMUX_WORKSPACE_ID"] = tabId.uuidString
// Backward-compatible shell integration keys used by existing scripts/tests.
env["CMUX_PANEL_ID"] = id.uuidString
env["CMUX_TAB_ID"] = tabId.uuidString
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
env["CMUX_BUNDLE_ID"] = bundleId
}
// Port range for this workspace (base/range snapshotted once per app session)
do {
let startPort = Self.sessionPortBase + portOrdinal * Self.sessionPortRangeSize
env["CMUX_PORT"] = String(startPort)
env["CMUX_PORT_END"] = String(startPort + Self.sessionPortRangeSize - 1)
env["CMUX_PORT_RANGE"] = String(Self.sessionPortRangeSize)
}
let claudeHooksEnabled = ClaudeCodeIntegrationSettings.hooksEnabled()
if !claudeHooksEnabled {
env["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 ? "" : ":"
env["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 {
env["CMUX_SHELL_INTEGRATION"] = "1"
env["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" {
env["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 {
env["CMUX_ZSH_ZDOTDIR"] = candidateZdotdir
}
}
env["ZDOTDIR"] = integrationDir
}
}
if !additionalEnvironment.isEmpty {
for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty {
env[key] = value
}
}
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)
}
}
if let workingDirectory, !workingDirectory.isEmpty {
workingDirectory.withCString { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
createSurface()
}
} else {
createSurface()
}
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..<count {
let diag = ghostty_config_get_diagnostic(cfg, UInt32(i))
let msg = diag.message.flatMap { String(cString: $0) } ?? "(null)"
Self.surfaceLog(" [\(i)] \(msg)")
}
} else {
Self.surfaceLog("createSurface diagnostics: config=nil")
}
#endif
return
}
guard let createdSurface = surface else { return }
// For vsync-driven rendering, Ghostty needs to know which display we're on so it can
// start a CVDisplayLink with the right refresh rate. If we don't set this early, the
// renderer can believe vsync is "running" but never deliver frames, which looks like a
// frozen terminal until focus/visibility changes force a synchronous draw.
//
// `view.window?.screen` can be transiently nil during early attachment; fall back to the
// primary screen so we always set *some* display ID, then update again on screen changes.
if let screen = view.window?.screen ?? NSScreen.main,
let displayID = screen.displayID,
displayID != 0 {
ghostty_surface_set_display_id(createdSurface, displayID)
}
ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y)
let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size
let wpx = pixelDimension(from: backingSize.width)
let hpx = pixelDimension(from: backingSize.height)
if wpx > 0, hpx > 0 {
ghostty_surface_set_size(createdSurface, wpx, hpx)
lastPixelWidth = wpx
lastPixelHeight = hpx
lastXScale = scaleFactors.x
lastYScale = scaleFactors.y
}
// Some GhosttyKit builds can drop inherited font_size during post-create
// config/scale reconciliation. If runtime points don't match the inherited
// template points, re-apply via binding action so all creation paths
// (new surface, split, new workspace) preserve zoom from the source terminal.
if let inheritedFontPoints = configTemplate?.font_size,
inheritedFontPoints > 0 {
let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface)
let shouldReapply = {
guard let currentFontPoints else { return true }
return abs(currentFontPoints - inheritedFontPoints) > 0.05
}()
if shouldReapply {
let action = String(format: "set_font_size:%.3f", inheritedFontPoints)
_ = performBindingAction(action)
}
}
flushPendingTextIfNeeded()
// 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
let ts = ISO8601DateFormatter().string(from: Date())
let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n"
let logPath = "/tmp/cmux-refresh-debug.log"
if let handle = FileHandle(forWritingAtPath: logPath) {
handle.seekToEndOfFile()
handle.write(line.data(using: .utf8)!)
handle.closeFile()
} else {
FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8))
}
#endif
guard let view = attachedView,
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()
}
func setFocus(_ focused: Bool) {
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 {
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)
}
func requestBackgroundSurfaceStartIfNeeded() {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.requestBackgroundSurfaceStartIfNeeded()
}
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.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
/// 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
}
surface = nil
ghostty_surface_free(surfaceToFree)
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
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
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
}
}
}
// 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")
}()
private static let dropTypes: Set<NSPasteboard.PasteboardType> = [
.string,
.fileURL,
.URL
]
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
fileprivate static func focusLog(_ message: String) {
guard focusDebugEnabled else { return }
FocusLogStore.shared.append(message)
NSLog("[FOCUSDBG] %@", message)
}
weak var terminalSurface: TerminalSurface?
var scrollbar: GhosttyScrollbar?
var cellSize: CGSize = .zero
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 keyboardCopyModeConsumedKeyUps: Set<UInt16> = []
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, not for libghostty occlusion.
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()
}
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
} 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
}
#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)
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()
}
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()
}
override func layout() {
super.layout()
updateSurfaceSize()
}
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 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
guard !Self.shouldDeferSurfaceResizeForActiveDrag() else {
scheduleDeferredSurfaceSizeRetryIfNeeded()
#if DEBUG
let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
if lastSizeSkipSignature != signature {
dlog(
"surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=tabDrag " +
"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)
}
/// 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
}
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 surface = 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) }
guard let ptr = text.text, text.text_len > 0 else { return nil }
let selectedData = Data(bytes: ptr, count: Int(text.text_len))
let selected = String(decoding: selectedData, as: UTF8.self)
return selected.isEmpty ? nil : selected
}
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,
]
)
}
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
}
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?
#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 }
guard event.timestamp > 0 else { return }
let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000)
let delayText = String(format: "%.2f", delayMs)
dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
}
#endif
// Prevents NSBeep for unimplemented actions from interpretKeyEvents
override func doCommand(by selector: Selector) {
// Intentionally empty - prevents system beep on unhandled key commands
}
/// 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 {
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 = event.characters ?? ""
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.characters ?? ""
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) {
guard let surface = ensureSurfaceReadyForInput() else {
super.keyDown(with: event)
return
}
if let terminalSurface {
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
tabId: terminalSurface.tabId,
surfaceId: terminalSurface.id
)
}
if event.keyCode != 53 {
endFindEscapeSuppression()
}
if shouldConsumeSuppressedFindEscape(event) {
return
}
if handleKeyboardCopyModeIfNeeded(event, surface: surface) {
keyboardCopyModeConsumedKeyUps.insert(event.keyCode)
return
}
#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() {
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
handled = ghostty_surface_key(surface, keyEvent)
} else {
handled = text.withCString { ptr in
keyEvent.text = ptr
return ghostty_surface_key(surface, keyEvent)
}
}
#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.)
interpretKeyEvents([translationEvent])
// 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 {
syncPreedit(clearIfNeeded: markedTextBefore)
return
}
// Sync the preedit state with Ghostty so it can render the IME
// composition overlay (e.g. for Korean, Japanese, Chinese input).
syncPreedit(clearIfNeeded: markedTextBefore)
// 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
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
} else {
keyEvent.text = nil
_ = ghostty_surface_key(surface, keyEvent)
}
}
} 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
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
} else {
keyEvent.text = nil
_ = ghostty_surface_key(surface, keyEvent)
}
} else {
keyEvent.text = nil
_ = ghostty_surface_key(surface, keyEvent)
}
}
if shouldRefreshAfterTextInput {
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
}
// 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)
}
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)
}
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 scalar.value < 0x20 {
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 shouldSendText(_ text: String) -> Bool {
guard let first = text.utf8.first else { return false }
return first >= 0x20
}
/// 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 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)
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 }
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
}
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: "Trigger Flash", action: #selector(triggerFlash(_:)), keyEquivalent: "")
flashItem.target = self
menu.addItem(.separator())
}
if ghostty_surface_has_selection(surface) {
let item = menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "")
item.target = self
}
let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "")
pasteItem.target = self
menu.addItem(.separator())
let splitHorizontallyItem = menu.addItem(
withTitle: "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: "Split Vertically",
action: #selector(splitVertically(_:)),
keyEquivalent: "d"
)
splitVerticallyItem.target = self
splitVerticallyItem.keyEquivalentModifierMask = [.command]
splitVerticallyItem.image = NSImage(
systemSymbolName: "rectangle.righthalf.inset.filled",
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.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
}
@objc private func triggerFlash(_ sender: Any?) {
onTriggerFlash?()
}
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))
}
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) {
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) {
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 {
var result = value
for char in shellEscapeCharacters {
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
}
return result
}
private func droppedContent(from pasteboard: NSPasteboard) -> String? {
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty {
return urls
.map { Self.escapeDropForShell($0.path) }
.joined(separator: " ")
}
if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty {
return Self.escapeDropForShell(rawURL)
}
if let str = pasteboard.string(forType: .string), !str.isEmpty {
return str
}
return nil
}
@discardableResult
fileprivate func insertDroppedPasteboard(_ pasteboard: NSPasteboard) -> Bool {
guard let content = droppedContent(from: pasteboard) else { return false }
// 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.
terminalSurface?.sendText(content)
return true
}
#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 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
}
}
final class GhosttySurfaceScrollView: NSView {
enum FlashStyle {
case standardFocus
case notificationDismiss
}
private enum NotificationRingMetrics {
static let inset: CGFloat = 2
static let cornerRadius: CGFloat = 6
}
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 let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
private let keyboardCopyModeBadgeIconView: NSImageView
private let keyboardCopyModeBadgeLabel: NSTextField
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
private var lastSearchOverlayStateID: ObjectIdentifier?
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
private var isLiveScrolling = false
private var lastSentRow: Int?
private var isActive = true
private var lastFocusRefreshAt: CFTimeInterval = 0
private var activeDropZone: DropZone?
private var pendingDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
// 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
private static func panelBackgroundFillColor(for terminalBackgroundColor: NSColor) -> NSColor {
// The Ghostty renderer already draws translucent terminal backgrounds. If we paint an
// additional translucent layer here, alpha stacks and appears effectively opaque.
terminalBackgroundColor.alphaComponent < 0.999 ? .clear : terminalBackgroundColor
}
#if DEBUG
private var lastDropZoneOverlayLogSignature: 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
)
}
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)
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)
let initialPanelFill = Self.panelBackgroundFillColor(for: initialTerminalBackground)
backgroundView.layer?.backgroundColor = initialPanelFill.cgColor
backgroundView.layer?.isOpaque = initialPanelFill.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
addSubview(dropZoneOverlayView)
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 = 2.5
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 = NSColor.systemBlue.cgColor
flashLayer.lineWidth = 3
flashLayer.lineJoin = .round
flashLayer.lineCap = .round
flashLayer.shadowColor = NSColor.systemBlue.cgColor
flashLayer.shadowOpacity = 0.6
flashLayer.shadowRadius = 6
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),
])
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
})
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: .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()
})
}
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) }
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()
}
/// 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 {
_ = 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)
updateNotificationRingPath()
updateFlashPath(style: .standardFocus)
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
}
#if DEBUG
private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool {
switch eventType {
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
return true
default:
return false
}
}
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"
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) " +
"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.applyFirstResponderIfNeeded()
})
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 { applyFirstResponderIfNeeded() }
}
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 }
let fillColor = Self.panelBackgroundFillColor(for: color)
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.backgroundColor = fillColor.cgColor
layer.isOpaque = fillColor.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()
}
func setSearchOverlay(searchState: TerminalSurface.SearchState?) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.setSearchOverlay(searchState: searchState)
}
return
}
// 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
guard hadOverlay else { return }
#if DEBUG
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
#endif
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
searchFocusTarget = .searchField
return
}
let searchStateID = ObjectIdentifier(searchState)
if let overlay = searchOverlayHostingView,
lastSearchOverlayStateID == searchStateID,
overlay.superview === self {
if !keyboardCopyModeBadgeContainerView.isHidden {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, 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 tabId = terminalSurface.tabId
let surfaceId = terminalSurface.id
let rootView = SurfaceSearchOverlay(
tabId: tabId,
surfaceId: surfaceId,
searchState: searchState,
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()
}
)
if let overlay = searchOverlayHostingView {
overlay.rootView = rootView
if overlay.superview !== self {
overlay.removeFromSuperview()
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
if !keyboardCopyModeBadgeContainerView.isHidden {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
}
lastSearchOverlayStateID = searchStateID
return
}
searchFocusTarget = .searchField
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
if !keyboardCopyModeBadgeContainerView.isHidden {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
}
searchOverlayHostingView = overlay
lastSearchOverlayStateID = searchStateID
}
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 {
if let overlay = searchOverlayHostingView {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
} else {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil)
}
}
return
}
keyboardCopyModeBadgeIconView.setAccessibilityLabel(terminalKeyTableIndicatorAccessibilityLabel)
keyboardCopyModeBadgeContainerView.isHidden = true
}
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let padding: CGFloat = 4
switch zone {
case .center:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
case .left:
return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .right:
return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .top:
return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
case .bottom:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
}
}
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
let previousFrame = dropZoneOverlayView.frame
if let zone {
#if DEBUG
if window == nil {
logDropZoneOverlay(event: "showNoWindow", zone: zone, frame: nil)
}
#endif
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
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 {
dropZoneOverlayView.frame = 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 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)|\(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)"
)
}
#endif
func triggerFlash(style: FlashStyle = .standardFocus) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
#if DEBUG
if let surfaceId = self.surfaceView.terminalSurface?.id {
Self.recordFlash(for: surfaceId)
}
#endif
self.updateFlashPath(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 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 !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 {
applyFirstResponderIfNeeded()
}
}
var debugPortalVisibleInUI: Bool {
surfaceView.isVisibleInUI
}
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 {
applyFirstResponderIfNeeded()
} 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 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
)
}
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
/// Handle file/URL drops, forwarding to the terminal as shell-escaped paths.
func handleDroppedURLs(_ urls: [URL]) -> Bool {
guard !urls.isEmpty else { return false }
let content = urls
.map { GhosttyNSView.escapeDropForShell($0.path) }
.joined(separator: " ")
#if DEBUG
dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))")
#endif
surfaceView.terminalSurface?.sendText(content)
return true
}
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, attemptsRemaining: Int = 3) {
func retry() {
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
self?.ensureFocus(for: tabId, surfaceId: surfaceId, attemptsRemaining: attemptsRemaining - 1)
}
}
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 attempts=\(attemptsRemaining)"
)
#endif
retry()
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)) attempts=\(attemptsRemaining)"
)
#endif
retry()
return
}
guard let delegate = AppDelegate.shared,
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
tabManager.selectedTabId == tabId else {
retry()
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 {
retry()
return
}
guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface,
tab.bonsplitController.focusedPaneId == paneId else {
retry()
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)) " +
"attempts=\(attemptsRemaining) 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 {
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)) " +
"attempts=\(attemptsRemaining)"
)
#endif
if !isSurfaceViewFirstResponder() {
retry()
} 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
}
/// 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 reassertTerminalSurfaceFocus(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface else { return }
#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,
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<SurfaceSearchOverlay> { 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)
}
#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 }
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 {
let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth())
let height = surfaceView.frame.height
guard width > 0, height > 0 else { return false }
return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
}
/// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller.
private func overlayScrollbarInsetWidth() -> CGFloat {
guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 }
// If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction.
let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width)
if alreadyReserved > 0.5 { return 0 }
let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay)
guard let verticalScroller = scrollView.verticalScroller else { return fallback }
let measuredWidth = verticalScroller.frame.width
if measuredWidth > 0 {
return max(measuredWidth, fallback)
}
let controlSizeWidth = NSScroller.scrollerWidth(
for: verticalScroller.controlSize,
scrollerStyle: .overlay
)
return max(controlSizeWidth, fallback)
}
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 .standardFocus:
inset = CGFloat(FocusFlashPattern.ringInset)
radius = CGFloat(FocusFlashPattern.ringCornerRadius)
case .notificationDismiss:
inset = NotificationRingMetrics.inset
radius = NotificationRingMetrics.cornerRadius
}
updateOverlayRingPath(
layer: flashLayer,
bounds: flashOverlayView.bounds,
inset: inset,
radius: radius
)
}
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 = bounds.insetBy(dx: inset, dy: inset)
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)
if !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) {
scrollView.contentView.scroll(to: targetOrigin)
didChangeGeometry = true
}
lastSentRow = Int(scrollbar.offset)
}
}
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
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
}
surfaceView.scrollbar = scrollbar
synchronizeScrollView()
}
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
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)
}
}
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 {
return NSRange(location: NSNotFound, length: 0)
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
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()
}
}
func unmarkText() {
if markedText.length > 0 {
markedText.mutableString.setString("")
syncPreedit()
}
}
/// 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) {
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? {
return nil
}
func characterIndex(for point: NSPoint) -> Int {
return 0
}
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 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 let surface = surface {
ghostty_surface_ime_point(surface, &x, &y, &w, &h)
}
#endif
// 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 insertText(_ string: Any, replacementRange: NSRange) {
// 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
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 {
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
private(set) var geometryRevision: UInt64 = 0
private var lastReportedGeometryState: GeometryState?
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
}
func makeNSView(context: Context) -> NSView {
let container = HostContainerView()
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),
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()
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),
inWindow: host.window != nil,
bounds: host.bounds,
reason: "didMoveToWindow"
) else { return }
guard host.window != nil 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),
inWindow: host.window != nil,
bounds: host.bounds,
reason: "geometryChanged"
) 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)
}
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
}
if host.window != nil, hostOwnsPortalNow {
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 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 coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
}
} else if hostOwnsPortalNow {
// 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 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?.releaseOwnedPortalHost(
hostId: ObjectIdentifier(host),
reason: "dismantle"
)
}
// SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split
// tree updates. Do not 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() }
}
}