cmux/Sources/GhosttyTerminalView.swift

3854 lines
150 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)
private func cmuxShouldUseTransparentBackgroundWindow() -> Bool {
let defaults = UserDefaults.standard
let sidebarBlendMode = defaults.string(forKey: "sidebarBlendMode") ?? "withinWindow"
let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? true
return sidebarBlendMode == "behindWindow" && bgGlassEnabled && !WindowGlassEffect.isAvailable
}
#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 }
return (stringContents(from: pasteboard) ?? "").isEmpty == false
}
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)
}
private static func escapeForShell(_ value: String) -> String {
var result = value
for char in shellEscapeCharacters {
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
}
return result
}
}
// 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(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
let backgroundLogEnabled = {
if ProcessInfo.processInfo.environment["CMUX_DEBUG_BG"] == "1" {
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 = URL(fileURLWithPath: "/tmp/cmux-bg.log")
private var appObservers: [NSObjectProtocol] = []
// 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 = 25 // Alert if tick takes >25ms during scroll
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
if maxLag > threshold {
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")
}
}
// 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.
ghostty_config_load_default_files(primaryConfig)
loadLegacyGhosttyConfigIfNeeded(primaryConfig)
ghostty_config_finalize(primaryConfig)
updateDefaultBackground(from: 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 userdata else { return }
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
guard let surface = surfaceView.terminalSurface?.surface else { return }
let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location)
let value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? ""
value.withCString { ptr in
ghostty_surface_complete_clipboard_request(surface, ptr, state, false)
}
}
runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in
guard let userdata, let content else { return }
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
guard let surface = surfaceView.terminalSurface?.surface 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 userdata else { return }
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
let callbackSurfaceId = surfaceView.terminalSurface?.id
let callbackTabId = surfaceView.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 callbackSurfaceId,
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)
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).
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 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 let newSize = fileSize(configNew), newSize == 0 else { return }
guard let legacySize = fileSize(configLegacy), legacySize > 0 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) {
guard let app else { return }
if soft, let config {
ghostty_app_update_config(app, config)
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
return
}
guard let newConfig = ghostty_config_new() else { return }
ghostty_config_load_default_files(newConfig)
ghostty_config_finalize(newConfig)
ghostty_app_update_config(app, newConfig)
updateDefaultBackground(from: newConfig)
DispatchQueue.main.async {
self.applyBackgroundToKeyWindow()
}
if let oldConfig = config {
ghostty_config_free(oldConfig)
}
config = newConfig
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
}
func reloadConfiguration(for surface: ghostty_surface_t, soft: Bool = false) {
if soft, let config {
ghostty_surface_update_config(surface, config)
return
}
guard let newConfig = ghostty_config_new() else { return }
ghostty_config_load_default_files(newConfig)
ghostty_config_finalize(newConfig)
ghostty_surface_update_config(surface, newConfig)
ghostty_config_free(newConfig)
}
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 updateDefaultBackground(from config: ghostty_config_t?) {
guard let config else { return }
var color = ghostty_config_color_s()
let bgKey = "background"
if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) {
defaultBackgroundColor = NSColor(
red: CGFloat(color.r) / 255,
green: CGFloat(color.g) / 255,
blue: CGFloat(color.b) / 255,
alpha: 1.0
)
}
var opacity: Double = 1.0
let opacityKey = "background-opacity"
_ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8)))
defaultBackgroundOpacity = opacity
if backgroundLogEnabled {
logBackground("default background updated color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity))")
}
}
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 func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool {
if target.tag != GHOSTTY_TARGET_SURFACE {
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)
tabManager.moveTabToTop(tabId)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
title: command,
subtitle: "",
body: body
)
return true
}
}
if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
let soft = action.action.reload_config.soft
performOnMain {
GhosttyApp.shared.reloadConfiguration(soft: soft)
}
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
defaultBackgroundColor = NSColor(
red: CGFloat(change.r) / 255,
green: CGFloat(change.g) / 255,
blue: CGFloat(change.b) / 255,
alpha: 1.0
)
if backgroundLogEnabled {
logBackground("OSC background change (app target) color=\(defaultBackgroundColor)")
}
DispatchQueue.main.async {
GhosttyApp.shared.applyBackgroundToKeyWindow()
}
return true
}
if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE {
updateDefaultBackground(from: action.action.config_change.config)
DispatchQueue.main.async {
GhosttyApp.shared.applyBackgroundToKeyWindow()
}
return true
}
return false
}
guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false }
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
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_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
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
title: command,
subtitle: "",
body: body
)
}
return true
case 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
cmuxWriteChildExitProbe(
[
"probeShowChildExitedTabId": surfaceView.tabId?.uuidString ?? "",
"probeShowChildExitedSurfaceId": surfaceView.terminalSurface?.id.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 tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id,
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager,
let workspace = manager.tabs.first(where: { $0.id == tabId }),
workspace.panels[surfaceId] != nil {
manager.closePanelAfterChildExited(tabId: tabId, surfaceId: surfaceId)
}
}
// Always report handled so Ghostty doesn't print the fallback prompt.
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
)
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:
updateDefaultBackground(from: action.action.config_change.config)
DispatchQueue.main.async {
surfaceView.applyWindowBackgroundIfActive()
}
return true
case GHOSTTY_ACTION_RELOAD_CONFIG:
let soft = action.action.reload_config.soft
return performOnMain {
if let surface = surfaceView.terminalSurface?.surface {
GhosttyApp.shared.reloadConfiguration(for: surface, soft: soft)
} else {
GhosttyApp.shared.reloadConfiguration(soft: soft)
}
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(cString: cstr)
guard let url = URL(string: urlString) else { return false }
guard let tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id else { return false }
return performOnMain {
guard let app = AppDelegate.shared,
let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager,
let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else {
return false
}
if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: surfaceId) {
return workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil
} else {
return workspace.newBrowserSplit(from: surfaceId, orientation: .horizontal, url: url) != nil
}
}
default:
return false
}
}
private func applyBackgroundToKeyWindow() {
guard let window = activeMainWindow() else { return }
if cmuxShouldUseTransparentBackgroundWindow() {
window.backgroundColor = .clear
window.isOpaque = false
if backgroundLogEnabled {
logBackground("applied transparent window for behindWindow blur")
}
} 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 line = "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
private let surfaceContext: ghostty_surface_context_e
private let configTemplate: ghostty_surface_config_s?
private let workingDirectory: 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
@Published var searchState: SearchState? = nil {
didSet {
if let searchState {
hostedView.cancelFocusRequest()
NSLog("Find: search state created tab=%@ surface=%@", tabId.uuidString, id.uuidString)
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
NSLog("Find: needle updated tab=%@ surface=%@ needle=%@", self?.tabId.uuidString ?? "unknown", self?.id.uuidString ?? "unknown", needle)
_ = self?.performBindingAction("search:\(needle)")
}
} else if oldValue != nil {
searchNeedleCancellable = nil
NSLog("Find: search state cleared tab=%@ surface=%@", tabId.uuidString, id.uuidString)
_ = performBindingAction("end_search")
}
}
}
private var searchNeedleCancellable: AnyCancellable?
init(
tabId: UUID,
context: ghostty_surface_context_e,
configTemplate: ghostty_surface_config_s?,
workingDirectory: String? = nil
) {
self.id = UUID()
self.tabId = tabId
self.surfaceContext = context
self.configTemplate = configTemplate
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
// 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
}
#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
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
print("[TerminalSurface] attachToView: \(id) attachedView=\(attachedView != nil) surface=\(surface != nil)")
#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.
if attachedView === view && surface != nil {
#if DEBUG
print("[TerminalSurface] attachToView: same view and surface exists")
#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)
}
view.forceRefreshSurface()
return
}
if let attachedView, attachedView !== view {
#if DEBUG
print("[TerminalSurface] attachToView: different view, returning")
#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 { return }
#if DEBUG
print("[TerminalSurface] attachToView: creating surface for \(id)")
#endif
createSurface(for: view)
#if DEBUG
print("[TerminalSurface] attachToView: after createSurface, surface=\(surface != nil)")
#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)
}
}
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()
))
surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque()
surfaceConfig.scale_factor = scaleFactors.layer
surfaceConfig.context = surfaceContext
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()
let claudeHooksEnabled = UserDefaults.standard.object(forKey: "claudeCodeHooksEnabled") as? Bool ?? true
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" {
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 !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 {
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
}
// 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(surface, displayID)
}
ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y)
let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero))
let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero))
if wpx > 0, hpx > 0 {
ghostty_surface_set_size(surface, wpx, hpx)
lastPixelWidth = wpx
lastPixelHeight = hpx
lastXScale = scaleFactors.x
lastYScale = scaleFactors.y
}
}
func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) {
guard let surface = surface else { return }
_ = layerScale
let wpx = UInt32((width * xScale).rounded(.toNearestOrAwayFromZero))
let hpx = UInt32((height * yScale).rounded(.toNearestOrAwayFromZero))
guard wpx > 0, hpx > 0 else { return }
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 }
#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.
}
/// Force a full size recalculation and surface redraw.
func forceRefresh() {
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)"
} else {
viewState = "NO_ATTACHED_VIEW"
}
#if DEBUG
let ts = ISO8601DateFormatter().string(from: Date())
let line = "[\(ts)] forceRefresh: \(id) \(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
}
view.forceRefreshSurface()
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 surface = surface else { return }
guard let data = text.data(using: .utf8), !data.isEmpty else { return }
data.withUnsafeBytes { rawBuffer in
guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return }
ghostty_surface_text(surface, baseAddress, UInt(rawBuffer.count))
}
}
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)))
}
}
func hasSelection() -> Bool {
guard let surface = surface else { return false }
return ghostty_surface_has_selection(surface)
}
deinit {
if let surface = surface {
ghostty_surface_free(surface)
}
}
}
// 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 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 keySequence: [ghostty_input_trigger_s] = []
private var keyTables: [String] = []
#if DEBUG
private static let keyLatencyProbeEnabled: Bool = {
if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" {
return true
}
return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")
}()
#endif
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
private var windowObserver: NSObjectProtocol?
private var lastScrollEventTime: CFTimeInterval = 0
private var visibleInUI: Bool = true
private var pendingSurfaceSize: CGSize?
// 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.
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)
layer.backgroundColor = color.cgColor
layer.isOpaque = color.alphaComponent >= 1.0
CATransaction.commit()
}
terminalSurface?.hostedView.setBackgroundColor(color)
}
func applyWindowBackgroundIfActive() {
guard let window else { return }
if let tabId, let selectedId = AppDelegate.shared?.tabManager?.selectedTabId, tabId != selectedId {
return
}
applySurfaceBackground()
let color = effectiveBackgroundColor()
if cmuxShouldUseTransparentBackgroundWindow() {
window.backgroundColor = .clear
window.isOpaque = false
} else {
window.backgroundColor = color
window.isOpaque = color.alphaComponent >= 1.0
}
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground("applied window background tab=\(tabId?.uuidString ?? "unknown") color=\(color) 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) {
terminalSurface = surface
tabId = surface.tabId
surface.attachToView(self)
updateSurfaceSize()
applySurfaceBackground()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let windowObserver {
NotificationCenter.default.removeObserver(windowObserver)
self.windowObserver = nil
}
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, not stale pending sizes.
superview?.layoutSubtreeIfNeeded()
layoutSubtreeIfNeeded()
let targetSize: CGSize = {
let current = bounds.size
if current.width > 0, current.height > 0 {
return current
}
return pendingSurfaceSize ?? current
}()
updateSurfaceSize(size: targetSize)
applySurfaceBackground()
applyWindowBackgroundIfActive()
}
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 updateSurfaceSize(size: CGSize? = nil) {
guard let terminalSurface = terminalSurface else { return }
let size = size ?? bounds.size
guard size.width > 0 && size.height > 0 else { return }
pendingSurfaceSize = size
guard let window else { return }
// 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 { return }
let xScale = backingSize.width / size.width
let yScale = backingSize.height / size.height
let layerScale = max(1.0, window.backingScaleFactor)
CATransaction.begin()
CATransaction.setDisableActions(true)
layer?.contentsScale = layerScale
if let metalLayer = layer as? CAMetalLayer {
metalLayer.drawableSize = backingSize
}
CATransaction.commit()
terminalSurface.updateSize(
width: size.width,
height: size.height,
xScale: xScale,
yScale: yScale,
layerScale: layerScale
)
pendingSurfaceSize = nil
}
fileprivate func pushTargetSurfaceSize(_ size: CGSize) {
updateSurfaceSize(size: size)
}
/// Force a full size recalculation and Metal layer refresh.
/// Resets cached metrics so updateSurfaceSize() re-runs unconditionally.
func forceRefreshSurface() {
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
}
@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)
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)))
}
}
// MARK: - Input Handling
@IBAction func copy(_ sender: Any?) {
_ = performBindingAction("copy_to_clipboard")
}
@IBAction func paste(_ sender: Any?) {
_ = performBindingAction("paste_from_clipboard")
}
@IBAction func pasteAsPlainText(_ sender: Any?) {
_ = performBindingAction("paste_from_clipboard")
}
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(_:)), #selector(pasteAsPlainText(_:)):
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
default:
return true
}
}
override var acceptsFirstResponder: Bool { true }
override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
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.
if isVisibleInUI {
onFocus?()
}
}
if result, 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
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
}
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 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 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) {
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 ?? "")
if text.isEmpty {
keyEvent.text = nil
_ = ghostty_surface_key(surface, keyEvent)
} else {
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
}
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 }
// Let the input system handle the event (for IME, dead keys, etc.)
interpretKeyEvents([translationEvent])
// 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.composing = markedText.length > 0
keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event)
// Use accumulated text from insertText (for IME), or compute text for key
if let accumulated = keyTextAccumulator, !accumulated.isEmpty {
for text in accumulated {
if shouldSendText(text) {
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
if let text = textForKeyEvent(translationEvent) {
if shouldSendText(text) {
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)
}
}
// Rendering is driven by Ghostty's wakeups/renderer.
}
override func keyUp(with event: NSEvent) {
guard let surface = surface else {
super.keyUp(with: event)
return
}
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_RELEASE
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)
}
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)
}
/// 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 {
// 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 {
return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control))
}
// 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 {
guard let chars = event.characters(byApplyingModifiers: []),
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
}
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)
if let namePtr, nameLen > 0 {
let data = Data(bytes: namePtr, count: nameLen)
if let name = String(data: data, encoding: .utf8) {
keyTables.append(name)
}
}
case GHOSTTY_KEY_TABLE_DEACTIVATE:
_ = keyTables.popLast()
case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL:
keyTables.removeAll()
default:
break
}
}
// MARK: - Mouse Handling
override func mouseDown(with event: NSEvent) {
#if DEBUG
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
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_LEFT, modsFromEvent(event))
}
override func mouseUp(with event: NSEvent) {
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) {
super.rightMouseDown(with: event)
return
}
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 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
return menu
}
@objc private func triggerFlash(_ sender: Any?) {
onTriggerFlash?()
}
override func mouseMoved(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 mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
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 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 let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
if let windowObserver {
NotificationCenter.default.removeObserver(windowObserver)
}
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 {
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"
}
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")
}
// MARK: - Scroll View Wrapper (Ghostty-style scrollbar)
private final class GhosttyScrollView: NSScrollView {
weak var surfaceView: GhosttyNSView?
override func scrollWheel(with event: NSEvent) {
guard let surfaceView else {
super.scrollWheel(with: event)
return
}
if let surface = surfaceView.terminalSurface?.surface,
ghostty_surface_mouse_captured(surface) {
GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: mouseCaptured -> surface scroll")
if window?.firstResponder !== surfaceView {
window?.makeFirstResponder(surfaceView)
}
surfaceView.scrollWheel(with: event)
} else {
GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: super scroll")
super.scrollWheel(with: event)
}
}
}
private final class GhosttyFlashOverlayView: NSView {
override var acceptsFirstResponder: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
final class GhosttySurfaceScrollView: NSView {
private let backgroundView: NSView
private let scrollView: GhosttyScrollView
private let documentView: NSView
private let surfaceView: GhosttyNSView
private let inactiveOverlayView: GhosttyFlashOverlayView
private let flashOverlayView: GhosttyFlashOverlayView
private let flashLayer: CAShapeLayer
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
private var isLiveScrolling = false
private var lastSentRow: Int?
private var isActive = true
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
#if DEBUG
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 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)
}
var debugSurfaceId: UUID? {
surfaceView.terminalSurface?.id
}
#endif
init(surfaceView: GhosttyNSView) {
self.surfaceView = surfaceView
backgroundView = NSView(frame: .zero)
scrollView = GhosttyScrollView()
inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero)
flashOverlayView = GhosttyFlashOverlayView(frame: .zero)
flashLayer = CAShapeLayer()
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)
backgroundView.wantsLayer = true
backgroundView.layer?.backgroundColor =
GhosttyApp.shared.defaultBackgroundColor
.withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity)
.cgColor
addSubview(backgroundView)
addSubview(scrollView)
inactiveOverlayView.wantsLayer = true
inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor
inactiveOverlayView.isHidden = true
addSubview(inactiveOverlayView)
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)
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: .ghosttyDidUpdateCellSize,
object: surfaceView,
queue: .main
) { [weak self] _ in
self?.synchronizeScrollView()
})
}
required init?(coder: NSCoder) {
fatalError("init(coder:) not implemented")
}
deinit {
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.
func reconcileGeometryNow() {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.reconcileGeometryNow()
}
return
}
synchronizeGeometryAndContent()
}
private func synchronizeGeometryAndContent() {
CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
backgroundView.frame = bounds
scrollView.frame = bounds
let targetSize = scrollView.bounds.size
surfaceView.frame.size = targetSize
surfaceView.pushTargetSurfaceSize(targetSize)
documentView.frame.size.width = scrollView.bounds.width
inactiveOverlayView.frame = bounds
flashOverlayView.frame = bounds
updateFlashPath()
synchronizeScrollView()
synchronizeSurfaceView()
}
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
self?.applyFirstResponderIfNeeded()
})
windowObservers.append(NotificationCenter.default.addObserver(
forName: NSWindow.didResignKeyNotification,
object: window,
queue: .main
) { [weak self] _ in
// No-op: focus is driven by first-responder changes.
_ = self
})
if window.isKeyWindow { applyFirstResponderIfNeeded() }
}
func attachSurface(_ terminalSurface: TerminalSurface) {
surfaceView.attachSurface(terminalSurface)
}
func setFocusHandler(_ handler: (() -> Void)?) {
surfaceView.onFocus = handler
}
func setTriggerFlashHandler(_ handler: (() -> Void)?) {
surfaceView.onTriggerFlash = handler
}
func setBackgroundColor(_ color: NSColor) {
guard let layer = backgroundView.layer else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.backgroundColor = color.cgColor
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 triggerFlash() {
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()
self.flashLayer.removeAllAnimations()
self.flashLayer.opacity = 0
let animation = CAKeyframeAnimation(keyPath: "opacity")
animation.values = [0, 1, 0, 1, 0]
animation.keyTimes = [0, 0.25, 0.5, 0.75, 1]
animation.duration = 0.9
animation.timingFunctions = [
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn),
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn)
]
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 {
debugLogWorkspaceSwitchTiming(
event: "ws.term.visible",
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)"
)
}
#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()
}
}
func setActive(_ active: Bool) {
let wasActive = isActive
isActive = active
#if DEBUG
if wasActive != active {
debugLogWorkspaceSwitchTiming(
event: "ws.term.active",
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)"
)
}
#endif
if active {
applyFirstResponderIfNeeded()
} else if let window,
let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
window.makeFirstResponder(nil)
}
}
#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)")
}
#endif
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
#if DEBUG
dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
let work = { [weak self] in
guard let self else { return }
guard let window = self.window else { return }
if let previous, previous !== self {
_ = previous.surfaceView.resignFirstResponder()
}
window.makeFirstResponder(self.surfaceView)
}
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
)
}
#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 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() -> 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: [.control],
timestamp: timestamp,
windowNumber: window.windowNumber,
context: nil,
characters: "\u{04}",
charactersIgnoringModifiers: "d",
isARepeat: false,
keyCode: 2
) else { return false }
guard let keyUp = NSEvent.keyEvent(
with: .keyUp,
location: .zero,
modifierFlags: [.control],
timestamp: timestamp + 0.001,
windowNumber: window.windowNumber,
context: nil,
characters: "\u{04}",
charactersIgnoringModifiers: "d",
isARepeat: false,
keyCode: 2
) else { return false }
surfaceView.keyDown(with: keyDown)
surfaceView.keyUp(with: keyUp)
return true
}
#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)
}
}
guard isActive else { return }
guard surfaceView.terminalSurface?.searchState == nil else { return }
guard let window else { return }
guard surfaceView.isVisibleInUI else {
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
}
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
return
}
if !window.isKeyWindow {
window.makeKeyAndOrderFront(nil)
}
_ = window.makeFirstResponder(surfaceView)
if !isSurfaceViewFirstResponder() {
retry()
}
}
/// 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 applyFirstResponderIfNeeded() {
guard isActive else { return }
guard surfaceView.isVisibleInUI else { return }
guard surfaceView.terminalSurface?.searchState == nil else { return }
guard let window, window.isKeyWindow else { return }
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
return
}
window.makeFirstResponder(surfaceView)
}
#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 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 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,
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,
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,
layerContentsKey: contentsKey
)
}
#endif
func cancelFocusRequest() {
// Intentionally no-op (no retry loops).
}
private func synchronizeSurfaceView() {
let visibleRect = scrollView.contentView.documentVisibleRect
surfaceView.frame.origin = visibleRect.origin
}
private func updateFlashPath() {
let inset: CGFloat = 2
let radius: CGFloat = 6
let bounds = flashOverlayView.bounds
flashLayer.frame = bounds
guard bounds.width > inset * 2, bounds.height > inset * 2 else {
flashLayer.path = nil
return
}
let rect = bounds.insetBy(dx: inset, dy: inset)
flashLayer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
}
private func synchronizeScrollView() {
documentView.frame.size.height = documentHeight()
if !isLiveScrolling {
let cellHeight = surfaceView.cellSize.height
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
let offsetY =
CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight
scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY))
lastSentRow = Int(scrollbar.offset)
}
}
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
}
}
func unmarkText() {
markedText.mutableString.setString("")
}
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)
}
let viewRect = NSRect(x: 0, y: 0, width: 0, height: 0)
let winRect = convert(viewRect, to: nil)
return window.convertToScreen(winRect)
}
func insertText(_ string: Any, replacementRange: NSRange) {
guard NSApp.currentEvent != nil else { return }
// 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()
// 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 {
let terminalSurface: TerminalSurface
var isActive: Bool = true
var isVisibleInUI: Bool = true
var portalZPriority: Int = 0
var showsInactiveOverlay: Bool = false
var inactiveOverlayColor: NSColor = .clear
var inactiveOverlayOpacity: Double = 0
var reattachToken: UInt64 = 0
var onFocus: ((UUID) -> Void)? = nil
var onTriggerFlash: (() -> Void)? = nil
private final class HostContainerView: NSView {
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
onDidMoveToWindow?()
onGeometryChanged?()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
onGeometryChanged?()
}
override func layout() {
super.layout()
onGeometryChanged?()
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
onGeometryChanged?()
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
onGeometryChanged?()
}
}
final class Coordinator {
var 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 desiredPortalZPriority: Int = 0
var lastBoundHostId: ObjectIdentifier?
weak var hostedView: GhosttySurfaceScrollView?
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSView {
let container = HostContainerView()
container.wantsLayer = false
return container
}
func updateNSView(_ nsView: NSView, context: Context) {
let hostedView = terminalSurface.hostedView
let coordinator = context.coordinator
#if DEBUG
let previousDesiredIsActive = coordinator.desiredIsActive
#endif
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority
coordinator.desiredIsActive = isActive
coordinator.desiredIsVisibleInUI = isVisibleInUI
coordinator.desiredPortalZPriority = portalZPriority
coordinator.hostedView = hostedView
#if DEBUG
if previousDesiredIsActive != isActive ||
previousDesiredIsVisibleInUI != isVisibleInUI ||
previousDesiredPortalZPriority != portalZPriority {
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)"
)
} else {
dlog(
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)"
)
}
}
#endif
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
hostedView.attachSurface(terminalSurface)
hostedView.setVisibleInUI(isVisibleInUI)
hostedView.setActive(isActive)
hostedView.setInactiveOverlay(
color: inactiveOverlayColor,
opacity: CGFloat(inactiveOverlayOpacity),
visible: showsInactiveOverlay
)
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
hostedView.setTriggerFlashHandler(onTriggerFlash)
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
if let host = nsView as? HostContainerView {
host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in
guard let host, let hostedView, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard host.window != nil else { return }
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
to: host,
visibleInUI: coordinator.desiredIsVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastBoundHostId = ObjectIdentifier(host)
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
hostedView.setActive(coordinator.desiredIsActive)
}
host.onGeometryChanged = { [weak host, weak coordinator] in
guard let host, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return }
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
}
if host.window != nil {
let hostId = ObjectIdentifier(host)
let shouldBindNow =
coordinator.lastBoundHostId != hostId ||
hostedView.superview == nil ||
previousDesiredIsVisibleInUI != isVisibleInUI ||
previousDesiredPortalZPriority != portalZPriority
if shouldBindNow {
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
to: host,
visibleInUI: coordinator.desiredIsVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastBoundHostId = hostId
}
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
}
}
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachGeneration += 1
coordinator.desiredIsActive = false
coordinator.desiredIsVisibleInUI = false
coordinator.desiredPortalZPriority = 0
coordinator.lastBoundHostId = nil
#if DEBUG
if let hostedView = coordinator.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")"
)
} else {
dlog("ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")")
}
}
#endif
if let host = nsView as? HostContainerView {
host.onDidMoveToWindow = nil
host.onGeometryChanged = nil
}
coordinator.hostedView?.setVisibleInUI(false)
coordinator.hostedView?.setActive(false)
coordinator.hostedView?.setInactiveOverlay(color: .clear, opacity: 0, visible: false)
coordinator.hostedView = nil
nsView.subviews.forEach { $0.removeFromSuperview() }
}
}