import Foundation import SwiftUI import AppKit import Metal import QuartzCore import Combine import Darwin import Sentry 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() } 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(0, nil) if result != GHOSTTY_SUCCESS { print("Failed to initialize ghostty: \(result)") return } // Load config config = ghostty_config_new() guard let config = config else { print("Failed to create ghostty config") return } // Load default config ghostty_config_load_default_files(config) ghostty_config_finalize(config) updateDefaultBackground(from: config) // 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.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.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, _ in guard let userdata else { return } let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id else { return } DispatchQueue.main.async { if let surface = surfaceView.terminalSurface, surface.needsConfirmClose() { AppDelegate.shared?.tabManager?.closePanelWithConfirmation( tabId: tabId, surfaceId: surfaceId ) return } _ = AppDelegate.shared?.tabManager?.closeSurface(tabId: tabId, surfaceId: surfaceId) } } // Create app app = ghostty_app_new(&runtimeConfig, config) if app == nil { print("Failed to create ghostty app") return } #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 } 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(_ work: () -> T) -> T { if Thread.isMainThread { return work() } return DispatchQueue.main.sync(execute: work) } private func splitDirection(from direction: ghostty_action_split_direction_e) -> SplitTree.NewDirection? { 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) -> SplitTree.FocusDirection? { switch direction { case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .previous case GHOSTTY_GOTO_SPLIT_NEXT: return .next case GHOSTTY_GOTO_SPLIT_UP: return .spatial(.up) case GHOSTTY_GOTO_SPLIT_DOWN: return .spatial(.down) case GHOSTTY_GOTO_SPLIT_LEFT: return .spatial(.left) case GHOSTTY_GOTO_SPLIT_RIGHT: return .spatial(.right) default: return nil } } private func resizeDirection(from direction: ghostty_action_resize_split_direction_e) -> SplitTree.Spatial.Direction? { 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 tabManager = AppDelegate.shared?.tabManager, let tabId = tabManager.selectedTabId { let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) DispatchQueue.main.async { 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.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), let tabManager = AppDelegate.shared?.tabManager else { return false } return performOnMain { 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), let tabManager = AppDelegate.shared?.tabManager else { return false } return performOnMain { 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), let tabManager = AppDelegate.shared?.tabManager else { return false } let amount = action.action.resize_split.amount return performOnMain { tabManager.resizeSplit( tabId: tabId, surfaceId: surfaceId, direction: direction, amount: amount ) } case GHOSTTY_ACTION_EQUALIZE_SPLITS: guard let tabId = surfaceView.tabId, let tabManager = AppDelegate.shared?.tabManager else { return false } return performOnMain { tabManager.equalizeSplits(tabId: tabId) } case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, let tabManager = AppDelegate.shared?.tabManager else { return false } return performOnMain { 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) } ?? "" let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody DispatchQueue.main.async { AppDelegate.shared?.tabManager?.moveTabToTop(tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, title: command, subtitle: "", body: body ) } return true case GHOSTTY_ACTION_COLOR_CHANGE: if action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change surfaceView.backgroundColor = NSColor( red: CGFloat(change.r) / 255, green: CGFloat(change.g) / 255, blue: CGFloat(change.b) / 255, alpha: 1.0 ) 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 } default: return false } } private func applyBackgroundToKeyWindow() { guard let window = activeMainWindow() else { return } // Check if sidebar uses behindWindow blur - if so, keep window non-opaque let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "withinWindow" if sidebarBlendMode == "behindWindow" { 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 keyWindow?.identifier?.rawValue == "cmux.main" { return keyWindow } return NSApp.windows.first(where: { $0.identifier?.rawValue == "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: - 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? let id: UUID let 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 @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 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) let view = GhosttyNSView(frame: .zero) self.surfaceView = view self.hostedView = GhosttySurfaceScrollView(surfaceView: view) // Surface is created when attached to a view hostedView.attachSurface(self) } private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) { let layerScale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 return (layerScale, layerScale, layerScale) } func attachToView(_ view: GhosttyNSView) { // If already attached to this view, nothing to do if attachedView === view && surface != nil { updateMetalLayer(for: view) return } if let attachedView, attachedView !== view { return } attachedView = view // If surface doesn't exist yet, create it if surface == nil { createSurface(for: view) } } private func createSurface(for view: GhosttyNSView) { guard let app = GhosttyApp.shared.app else { print("Ghostty app not initialized") return } let scaleFactors = scaleFactors(for: view) updateMetalLayer(for: view) var surfaceConfig = configTemplate ?? ghostty_surface_config_new() surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS surfaceConfig.platform.macos.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, UnsafeMutablePointer)] = [] defer { for (key, value) in envStorage { free(key) free(value) } } // IMPORTANT: // `surfaceConfig.env_vars` are *overrides* layered on top of Ghostty's // default subprocess environment. // // Do not seed this map with the full app environment: that would // bypass Ghostty's env filtering (e.g. LANGUAGE, Xcode DYLD vars) by // re-introducing stripped variables via overrides. 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.. 0 && view.bounds.height > 0 { metalLayer.drawableSize = CGSize( width: view.bounds.width * scale, height: view.bounds.height * scale ) } } } func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { guard let surface = surface else { return } ghostty_surface_set_content_scale(surface, xScale, yScale) ghostty_surface_set_size(surface, UInt32(width * xScale), UInt32(height * yScale)) ghostty_surface_refresh(surface) if let view = attachedView, let metalLayer = view.layer as? CAMetalLayer { metalLayer.contentsScale = layerScale metalLayer.drawableSize = CGSize(width: width * layerScale, height: height * layerScale) } } func applyWindowBackgroundIfActive() { surfaceView.applyWindowBackgroundIfActive() } func setFocus(_ focused: Bool) { guard let surface = surface else { return } ghostty_surface_set_focus(surface, focused) } 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") }() fileprivate static func focusLog(_ message: String) { guard focusDebugEnabled else { return } FocusLogStore.shared.append(message) NSLog("[FOCUSDBG] %@", message) } weak var terminalSurface: TerminalSurface? private var surfaceAttached = false var scrollbar: GhosttyScrollbar? var cellSize: CGSize = .zero var desiredFocus: 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] = [] private var eventMonitor: Any? private var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? private var lastSurfaceSize: CGSize = .zero private var lastContentScale: CGSize = .zero private var lastLayerScale: CGFloat = 0 private var hasSurfaceMetrics = false private var lastScrollEventTime: CFTimeInterval = 0 override func makeBackingLayer() -> CALayer { let metalLayer = CAMetalLayer() metalLayer.device = MTLCreateSystemDefaultDevice() metalLayer.pixelFormat = .bgra8Unorm metalLayer.framebufferOnly = true metalLayer.isOpaque = false metalLayer.backgroundColor = NSColor.clear.cgColor metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 return metalLayer } override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } private func setup() { wantsLayer = true layerContentsRedrawPolicy = .duringViewResize installEventMonitor() updateTrackingAreas() } 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 metalLayer = layer as? CAMetalLayer { metalLayer.backgroundColor = color.cgColor metalLayer.isOpaque = color.alphaComponent >= 1.0 } 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() // Check if sidebar uses behindWindow blur - if so, keep window non-opaque let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "withinWindow" if sidebarBlendMode == "behindWindow" { 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 surfaceAttached = false hasSurfaceMetrics = false attachSurfaceIfNeeded() } private func attachSurfaceIfNeeded() { guard !surfaceAttached else { return } guard let terminalSurface = terminalSurface else { return } guard bounds.width > 0 && bounds.height > 0 else { return } guard window != nil else { return } surfaceAttached = true terminalSurface.attachToView(self) terminalSurface.setFocus(desiredFocus) } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) self.windowObserver = nil } if let window { windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didChangeScreenNotification, object: window, queue: .main ) { [weak self] notification in self?.windowDidChangeScreen(notification) } attachSurfaceIfNeeded() updateSurfaceSize() applySurfaceBackground() applyWindowBackgroundIfActive() } } override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() if let window { CATransaction.begin() CATransaction.setDisableActions(true) layer?.contentsScale = window.backingScaleFactor CATransaction.commit() } updateSurfaceSize() } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) attachSurfaceIfNeeded() updateSurfaceSize() } override func layout() { super.layout() attachSurfaceIfNeeded() } override var isOpaque: Bool { false } private func updateSurfaceSize() { guard let terminalSurface = terminalSurface else { return } guard bounds.width > 0 && bounds.height > 0 else { return } let layerScale = window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 let xScale = layerScale let yScale = layerScale if hasSurfaceMetrics { let sameSize = nearlyEqual(lastSurfaceSize.width, bounds.width, epsilon: 0.01) && nearlyEqual(lastSurfaceSize.height, bounds.height, epsilon: 0.01) let sameScale = nearlyEqual(lastContentScale.width, xScale) && nearlyEqual(lastContentScale.height, yScale) && nearlyEqual(lastLayerScale, layerScale) if sameSize && sameScale { return } } lastSurfaceSize = bounds.size lastContentScale = CGSize(width: xScale, height: yScale) lastLayerScale = layerScale hasSurfaceMetrics = true terminalSurface.updateSize( width: bounds.width, height: bounds.height, xScale: xScale, yScale: yScale, layerScale: layerScale ) } private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool { abs(lhs - rhs) <= epsilon } // Convenience accessor for the ghostty surface private var surface: ghostty_surface_t? { terminalSurface?.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, let surface = surface { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") onFocus?() #if DEBUG if let terminalSurface { AppDelegate.shared?.recordJumpUnreadFocusIfExpected( tabId: terminalSurface.tabId, surfaceId: terminalSurface.id ) } #endif ghostty_surface_set_focus(surface, true) } return result } override func resignFirstResponder() -> Bool { return super.resignFirstResponder() } // For NSTextInputClient - accumulates text during key events private var keyTextAccumulator: [String]? = nil private var markedText = NSMutableAttributedString() private var lastPerformKeyEvent: TimeInterval? // 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 window?.firstResponder === self else { return false } guard let surface = surface else { return false } // 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 } // Only handle command/control-modified keys here. if !event.modifierFlags.contains(.command) && !event.modifierFlags.contains(.control) { 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 = surface else { super.keyDown(with: event) 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) } } } 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) { 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 } ghostty_surface_set_display_id(surface, screen.displayID ?? 0) DispatchQueue.main.async { [weak self] in self?.viewDidChangeBackingProperties() } } } private extension NSScreen { var displayID: UInt32? { deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 } } 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 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 private var focusWorkItem: DispatchWorkItem? #if DEBUG private static var flashCounts: [UUID: Int] = [:] 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 } #endif init(surfaceView: GhosttyNSView) { self.surfaceView = surfaceView backgroundView = NSView(frame: .zero) scrollView = GhosttyScrollView() 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 = false scrollView.contentView.drawsBackground = false scrollView.contentView.backgroundColor = .clear scrollView.surfaceView = surfaceView documentView = NSView(frame: .zero) documentView.wantsLayer = true documentView.layer?.backgroundColor = NSColor.clear.cgColor 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) 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() backgroundView.frame = bounds scrollView.frame = bounds surfaceView.frame.size = scrollView.bounds.size documentView.frame.size.width = scrollView.bounds.width 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?.updateFocusForWindow() self?.requestFocus() }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, object: window, queue: .main ) { [weak self] _ in self?.updateFocusForWindow() }) updateFocusForWindow() if window.isKeyWindow { requestFocus() } } 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 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 setActive(_ active: Bool) { isActive = active updateFocusForWindow() if active { requestFocus() } else { cancelFocusRequest() } } func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { let maxDelay: TimeInterval = 0.5 guard (delay ?? 0) < maxDelay else { return } let nextDelay: TimeInterval = if let delay { delay * 2 } else { 0.05 } let work = DispatchWorkItem { [weak self] in guard let self else { return } guard let window = self.window else { self.moveFocus(from: previous, delay: nextDelay) return } if let previous, previous !== self { _ = previous.surfaceView.resignFirstResponder() } window.makeFirstResponder(self.surfaceView) } let queue = DispatchQueue.main if let delay { queue.asyncAfter(deadline: .now() + delay, execute: work) } else { queue.async(execute: work) } } func ensureFocus(for tabId: UUID, surfaceId: UUID, attempt: Int = 0) { let maxAttempts = 6 guard attempt < maxAttempts else { return } guard let tabManager = AppDelegate.shared?.tabManager, tabManager.selectedTabId == tabId, tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } if surfaceView.terminalSurface?.searchState != nil { return } guard let window else { scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt) return } guard window.isKeyWindow else { scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt) return } if window.firstResponder === surfaceView { return } window.makeFirstResponder(surfaceView) if window.firstResponder !== surfaceView { scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt) } } private func scheduleFocusRetry(for tabId: UUID, surfaceId: UUID, attempt: Int) { let delay = 0.05 * pow(2.0, Double(attempt)) DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in self?.ensureFocus(for: tabId, surfaceId: surfaceId, attempt: attempt + 1) } } private func updateFocusForWindow() { let shouldFocus = isActive && (window?.isKeyWindow ?? false) surfaceView.desiredFocus = shouldFocus surfaceView.terminalSurface?.setFocus(shouldFocus) } private func requestFocus(delay: TimeInterval? = nil) { guard isActive else { return } if surfaceView.terminalSurface?.searchState != nil { return } let maxDelay: TimeInterval = 0.5 guard (delay ?? 0) < maxDelay else { return } let nextDelay: TimeInterval = if let delay { delay * 2 } else { 0.05 } cancelFocusRequest() let work = DispatchWorkItem { [weak self] in guard let self else { return } guard self.isActive else { return } if self.surfaceView.terminalSurface?.searchState != nil { return } guard let window = self.window else { self.requestFocus(delay: nextDelay) return } guard window.isKeyWindow else { return } if window.firstResponder === self.surfaceView { return } if let responder = window.firstResponder as? NSView, responder !== self.surfaceView { _ = responder.resignFirstResponder() } window.makeFirstResponder(self.surfaceView) if window.firstResponder !== self.surfaceView { self.requestFocus(delay: nextDelay) } } let queue = DispatchQueue.main focusWorkItem = work if let delay { queue.asyncAfter(deadline: .now() + delay, execute: work) } else { queue.async(execute: work) } } func cancelFocusRequest() { focusWorkItem?.cancel() focusWorkItem = nil } 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 { 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) { // 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 if let surface = surface { 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) } } } } // MARK: - SwiftUI Wrapper struct GhosttyTerminalView: NSViewRepresentable { let terminalSurface: TerminalSurface var isActive: Bool = true var onFocus: ((UUID) -> Void)? = nil var onTriggerFlash: (() -> Void)? = nil func makeNSView(context: Context) -> GhosttySurfaceScrollView { let view = terminalSurface.hostedView view.attachSurface(terminalSurface) view.setActive(isActive) view.setFocusHandler { onFocus?(terminalSurface.id) } view.setTriggerFlashHandler(onTriggerFlash) return view } func updateNSView(_ nsView: GhosttySurfaceScrollView, context: Context) { nsView.attachSurface(terminalSurface) nsView.setActive(isActive) nsView.setFocusHandler { onFocus?(terminalSurface.id) } nsView.setTriggerFlashHandler(onTriggerFlash) } }