native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
21 kB · 606 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607import AppKitimport GhosttyKitimport SwiftUIimport UniformTypeIdentifiers
nonisolated let ghosttyLogger = ProwlLogger("GhosttyRuntime")
// Explicitly isolated rather than relying on the module's MainActor default:// when a Release (whole-module) build's swiftmodule is deserialized by the test// target, default-isolation inference is lost for the `isolated deinit` check// and compilation fails with "containing class is not isolated to an actor".// The explicit attribute is semantically identical and serializes correctly.@MainActorfinal class GhosttyRuntime { nonisolated static let ghosttyExecutableCandidates = [ "/Applications/Ghostty.app/Contents/MacOS/ghostty", "/opt/homebrew/bin/ghostty", "/usr/local/bin/ghostty", ] nonisolated static let ghosttyCLICacheLock = NSLock() nonisolated(unsafe) static var cachedGhosttyExecutablePath: String? nonisolated(unsafe) static var ghosttyExecutableResolutionAttempted = false nonisolated(unsafe) static var cachedFallbackThemePair: GhosttyThemePair? // Prowl constructs a single GhosttyRuntime for the whole app lifetime // (see `ProwlApp.init`). This weak reference gives UI surfaces that // don't otherwise have access to the runtime (e.g. the Settings window) a // direct way to trigger app-level actions without threading the instance // through SwiftUI's environment or reducers. static weak var shared: GhosttyRuntime?
final class SurfaceReference { let surface: ghostty_surface_t var isValid = true
init(_ surface: ghostty_surface_t) { self.surface = surface }
func invalidate() { isValid = false } }
var config: ghostty_config_t? private(set) var app: ghostty_app_t? var observers: [NSObjectProtocol] = [] var surfaceRefs: [SurfaceReference] = [] var lastColorScheme: ghostty_color_scheme_e? var currentColorScheme: ColorScheme? var appKeybindOverrideContents = "" var appKeybindOverrideEntries: [String] = [] /// App-scoped `undo` / `redo` (no focused surface); see `+AppKey`. var onAppUndo: (() -> Bool)? var onAppRedo: (() -> Bool)? var appUndoRedoOutcome: Bool? var isDispatchingUndoRedoKey = false var themeFallbackOverrideContents = "" var runtimeOverrideSignature = "" var onConfigChange: (() -> Void)? var onQuit: (() -> Void)?
init(initialColorScheme: ColorScheme? = nil) { guard let config = Self.loadConfig() else { preconditionFailure("ghostty_config_new failed") } self.config = config
var runtimeConfig = ghostty_runtime_config_s( userdata: Unmanaged.passUnretained(self).toOpaque(), supports_selection_clipboard: true, wakeup_cb: { @Sendable userdata in GhosttyRuntime.wakeupCallback(userdata) }, action_cb: { @Sendable app, target, action in GhosttyRuntime.actionCallback(app, target, action) }, read_clipboard_cb: { @Sendable userdata, location, state in GhosttyRuntime.readClipboardCallback(userdata, location, state) }, confirm_read_clipboard_cb: { @Sendable userdata, string, state, request in GhosttyRuntime.confirmReadClipboardCallback(userdata, string, state, request) }, write_clipboard_cb: { @Sendable userdata, location, content, len, confirm in GhosttyRuntime.writeClipboardCallback(userdata, location, content, len, confirm) }, close_surface_cb: { @Sendable userdata, processAlive in GhosttyRuntime.closeSurfaceCallback(userdata, processAlive) } )
guard let app = ghostty_app_new(&runtimeConfig, config) else { preconditionFailure("ghostty_app_new failed") } self.app = app if let initialColorScheme { setColorScheme(initialColorScheme) }
Self.shared = self registerNotificationObservers() }
var appliedColorSchemeForTesting: ColorScheme? { currentColorScheme }
func registerNotificationObservers() { let center = NotificationCenter.default observers.append( center.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.setAppFocus(true) } }) observers.append( center.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.setAppFocus(false) } }) observers.append( center.addObserver( forName: NSTextInputContext.keyboardSelectionDidChangeNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { guard let app = self?.app else { return } ghostty_app_keyboard_changed(app) } })
let workspaceCenter = NSWorkspace.shared.notificationCenter observers.append( workspaceCenter.addObserver( forName: NSWorkspace.willSleepNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.logLifecycleEvent("workspaceWillSleep") } }) observers.append( workspaceCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.logLifecycleEvent("workspaceDidWake") } }) observers.append( workspaceCenter.addObserver( forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.logLifecycleEvent("screensDidSleep") } }) observers.append( workspaceCenter.addObserver( forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.logLifecycleEvent("screensDidWake") } }) }
isolated deinit { let center = NotificationCenter.default for observer in observers { center.removeObserver(observer) } if let app { ghostty_app_free(app) } if let config { ghostty_config_free(config) } }
func setAppFocus(_ focused: Bool) { if let app { ghostty_app_set_focus(app, focused) } }
func tick() { if let app { ghostty_app_tick(app) } }
func logLifecycleEvent(_ event: String) { let validSurfaceCount = surfaceRefs.reduce(into: 0) { count, ref in if ref.isValid { count += 1 } } ghosttyLogger.info( "[TerminalWake] event=\(event) appActive=\(NSApp.isActive) " + "windows=\(NSApp.windows.count) runtimeSurfaces=\(validSurfaceCount)" ) }
func setColorScheme(_ scheme: ColorScheme) { guard let app else { return } currentColorScheme = scheme let ghosttyScheme: ghostty_color_scheme_e = scheme == .dark ? GHOSTTY_COLOR_SCHEME_DARK : GHOSTTY_COLOR_SCHEME_LIGHT lastColorScheme = ghosttyScheme ghostty_app_set_color_scheme(app, ghosttyScheme) applyColorSchemeToSurfaces(ghosttyScheme) reconcileThemeFallback(for: scheme) }
func registerSurface(_ surface: ghostty_surface_t) -> SurfaceReference { let ref = SurfaceReference(surface) surfaceRefs.append(ref) surfaceRefs = surfaceRefs.filter { $0.isValid } if let lastColorScheme { ghostty_surface_set_color_scheme(surface, lastColorScheme) } return ref }
func unregisterSurface(_ ref: SurfaceReference) { ref.invalidate() surfaceRefs = surfaceRefs.filter { $0.isValid } }
func reloadConfig(soft: Bool, target: ghostty_target_s) { guard let app else { return } if soft, let config { guard let clone = ghostty_config_clone(config) else { return } applyConfig(clone, target: target, app: app) ghostty_config_free(clone) return } guard let config = Self.loadConfig() else { return } applyConfig(config, target: target, app: app) ghostty_config_free(config) }
/// Re-reads the user's Ghostty config from disk and re-applies Prowl's /// runtime overrides on top of it. Intended for the Settings UI so users /// can pick up edits without restarting the app. func reloadAppConfig() { // Force `applyRuntimeOverridesIfNeeded` to rebuild and push a fresh // config to ghostty, regardless of whether our override contents changed. runtimeOverrideSignature = "" applyRuntimeOverridesIfNeeded() }
func applyConfig( _ config: ghostty_config_t, target: ghostty_target_s, app: ghostty_app_t ) { switch target.tag { case GHOSTTY_TARGET_APP: ghostty_app_update_config(app, config) case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } ghostty_surface_update_config(surface, config) default: return } }
func applyColorSchemeToSurfaces(_ scheme: ghostty_color_scheme_e) { for ref in surfaceRefs where ref.isValid { ghostty_surface_set_color_scheme(ref.surface, scheme) } }
func setConfig(_ config: ghostty_config_t) { if let existing = self.config { ghostty_config_free(existing) } self.config = config }
func applyAppKeybindArguments(_ keybindArguments: [String]) { let entries = Self.keybindEntries(from: keybindArguments) let overrideEntries = Self.makeKeybindOverrideEntries( entries: entries, previousEntries: appKeybindOverrideEntries ) let contents = overrideEntries .map { "keybind = \($0)" } .joined(separator: "\n") guard contents != appKeybindOverrideContents else { return } appKeybindOverrideEntries = entries appKeybindOverrideContents = contents applyRuntimeOverridesIfNeeded() }
static func keybindEntries(from keybindArguments: [String]) -> [String] { let prefix = "--keybind=" return keybindArguments.compactMap { argument in guard argument.hasPrefix(prefix) else { return nil } return String(argument.dropFirst(prefix.count)) } }
static func makeKeybindOverrideEntries( entries: [String], previousEntries: [String] ) -> [String] { var unbindEntries: [String] = [] var seenTriggers = Set<String>() for entry in previousEntries + entries { guard let trigger = keybindTrigger(from: entry), seenTriggers.insert(trigger).inserted else { continue } unbindEntries.append("\(trigger)=unbind") } return unbindEntries + entries }
static func keybindTrigger(from entry: String) -> String? { guard let separator = entry.firstIndex(of: "=") else { return nil } let trigger = String(entry[..<separator]) return trigger.isEmpty ? nil : trigger }
static func loadConfig() -> ghostty_config_t? { guard let config = ghostty_config_new() else { return nil } ghostty_config_load_default_files(config) ghostty_config_load_recursive_files(config) ghostty_config_load_cli_args(config) loadTerminalProgramOverrides(into: config) ghostty_config_finalize(config) return config }
/// Reports Prowl in `TERM_PROGRAM` so programs detect the real host terminal; /// loaded after the user config so it wins. The version is always emitted /// because Ghostty's `env` map can override a key but not clear its seeded /// value, so a blank value falls back to a placeholder. nonisolated static func terminalProgramOverrides(version: String?) -> String { // Trim like Ghostty's `env` parser, which strips whitespace then drops a // now-empty value, leaving its seeded version. let trimmed = version?.trimmingCharacters(in: .whitespacesAndNewlines) let resolved = trimmed.flatMap { $0.isEmpty ? nil : $0 } ?? "unknown" return """ env = TERM_PROGRAM=prowl env = TERM_PROGRAM_VERSION=\(resolved) """ }
nonisolated private static var appVersion: String? { let info = Bundle.main.infoDictionary let candidates = [info?["CFBundleShortVersionString"], info?["CFBundleVersion"]] return candidates.lazy.compactMap { $0 as? String }.first { !$0.isEmpty } }
nonisolated static func loadTerminalProgramOverrides(into config: ghostty_config_t) { let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("prowl-ghostty-term-program.conf") do { try terminalProgramOverrides(version: appVersion).write(to: url, atomically: true, encoding: .utf8) } catch { ghosttyLogger.warning("Failed to write TERM_PROGRAM override file: \(error.localizedDescription)") return } url.path.withCString { path in ghostty_config_load_file(config, path) } }
func keyboardShortcut(for action: String) -> KeyboardShortcut? { guard let config else { return nil } let trigger = ghostty_config_trigger(config, action, UInt(action.lengthOfBytes(using: .utf8))) return Self.keyboardShortcut(for: trigger) }
func defaultFontSize() -> Float32 { guard let config else { return 0 } var value: Double = 0 let key = "font-size" guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) else { return 0 } return Float32(value) }
func commandPaletteEntries() -> [GhosttyCommand] { guard let config else { return [] } var value = ghostty_config_command_list_s() let key = "command-palette-entry" guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] } guard value.len > 0, let commands = value.commands else { return [] } let buffer = UnsafeBufferPointer(start: commands, count: Int(value.len)) return buffer.map(GhosttyCommand.init(cValue:)) }
func focusFollowsMouse() -> Bool { guard let config else { return false } var value = false let key = "focus-follows-mouse" _ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) return value }
/// Ghostty's `undo-timeout`: how long a closed pane or tab stays restorable. /// libghostty exports the duration in milliseconds; zero disables undo. func undoTimeout() -> Duration { let fallback: Duration = .seconds(5) guard let config else { return fallback } var value: UInt = 0 let key = "undo-timeout" guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) else { return fallback } return .milliseconds(value) }
func shouldShowScrollbar() -> Bool { guard let config else { return true } var valuePtr: UnsafePointer<CChar>? let key = "scrollbar" if ghostty_config_get(config, &valuePtr, key, UInt(key.lengthOfBytes(using: .utf8))), let ptr = valuePtr { return String(cString: ptr) != "never" } return true }
func backgroundOpacity() -> Double { guard let config else { return 1 } var value: Double = 1 let key = "background-opacity" _ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) return min(max(value, 0.001), 1) }
func unfocusedSplitOverlayOpacity() -> Double { guard let config else { return 0 } var value: Double = 0.85 let key = "unfocused-split-opacity" _ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) return min(max(1 - value, 0), 1) }
func unfocusedSplitFill() -> Color? { guard let config else { return nil } var color = ghostty_config_color_s() let fillKey = "unfocused-split-fill" if ghostty_config_get(config, &color, fillKey, UInt(fillKey.lengthOfBytes(using: .utf8))) { return Color(nsColor: NSColor(ghostty: color)) } let backgroundKey = "background" if ghostty_config_get(config, &color, backgroundKey, UInt(backgroundKey.lengthOfBytes(using: .utf8))) { return Color(nsColor: NSColor(ghostty: color)) } ghosttyLogger.warning( "Ghostty config missing both 'unfocused-split-fill' and 'background'; skipping unfocused split overlay." ) return nil }
func splitDividerColor() -> Color? { guard let config else { return nil } var color = ghostty_config_color_s() let key = "split-divider-color" guard ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } return Color(nsColor: NSColor(ghostty: color)) }
/// Reads a Prowl-specific override for the visible divider thickness from the /// primary Ghostty config file (the one returned by `ghostty_config_open_path`). /// Ghostty itself has no such option (and its own divider size is hardcoded), /// so we layer a `prowl-split-divider-width = N` directive on top of the /// user's existing Ghostty config to avoid carrying another fork patch. func splitDividerWidth() -> CGFloat? { Self.parseProwlSplitDividerWidth(at: Self.ghosttyConfigPath()) }
nonisolated static func ghosttyConfigPath() -> String? { let configStr = ghostty_config_open_path() defer { ghostty_string_free(configStr) } guard let ptr = configStr.ptr, configStr.len > 0 else { return nil } let path = String(data: Data(bytes: ptr, count: Int(configStr.len)), encoding: .utf8) guard let path, !path.isEmpty else { return nil } return path }
nonisolated static func parseProwlSplitDividerWidth(at path: String?) -> CGFloat? { guard let path else { return nil } guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } return parseProwlSplitDividerWidth(from: contents) }
nonisolated static func parseProwlSplitDividerWidth(from contents: String) -> CGFloat? { let key = "prowl-split-divider-width" var resolved: CGFloat? for rawLine in contents.split(whereSeparator: \.isNewline) { let trimmed = rawLine.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } guard let equalsIndex = trimmed.firstIndex(of: "=") else { continue } let lhs = trimmed[..<equalsIndex].trimmingCharacters(in: .whitespaces) guard lhs == key else { continue } let rhs = trimmed[trimmed.index(after: equalsIndex)...].trimmingCharacters(in: .whitespaces) guard let value = Double(rhs) else { continue } // Clamp to a sane visible range — 0 disables the visible bar while // keeping the invisible hit area, large values are capped to prevent // accidental absurdities from a typo. resolved = CGFloat(min(max(value, 0), 32)) } return resolved }
func backgroundColor() -> NSColor { backgroundColorFromConfig() ?? NSColor.windowBackgroundColor }
func scrollbarAppearanceName() -> NSAppearance.Name { backgroundColor().ghosttyIsLightColor ? .aqua : .darkAqua }
/// Returns a window background color that tints the macOS glass effect /// for non-opaque windows. macOS 26 renders a white-biased glass on /// non-opaque windows; darker colors need higher alpha to counteract /// the white bias, lighter colors need almost none. static func chromeBackgroundColor(for color: NSColor) -> NSColor { color.withAlphaComponent(0.7) }
func backgroundColorFromConfig() -> NSColor? { guard let config else { return nil } var color: ghostty_config_color_s = .init() let key = "background" if !ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8))) { return nil } return NSColor(ghostty: color) }
static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { let key: KeyEquivalent switch trigger.tag { case GHOSTTY_TRIGGER_PHYSICAL: guard let equiv = keyToEquivalent[trigger.key.physical] else { return nil } key = equiv case GHOSTTY_TRIGGER_UNICODE: guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } key = KeyEquivalent(Character(scalar)) case GHOSTTY_TRIGGER_CATCH_ALL: return nil default: return nil } return KeyboardShortcut(key, modifiers: eventModifiers(mods: trigger.mods)) }
static func eventModifiers(mods: ghostty_input_mods_e) -> EventModifiers { var flags: EventModifiers = [] if mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0 { flags.insert(.shift) } if mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { flags.insert(.control) } if mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0 { flags.insert(.option) } if mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0 { flags.insert(.command) } return flags }
static let keyToEquivalent: [ghostty_input_key_e: KeyEquivalent] = [ GHOSTTY_KEY_ARROW_UP: .upArrow, GHOSTTY_KEY_ARROW_DOWN: .downArrow, GHOSTTY_KEY_ARROW_LEFT: .leftArrow, GHOSTTY_KEY_ARROW_RIGHT: .rightArrow, GHOSTTY_KEY_HOME: .home, GHOSTTY_KEY_END: .end, GHOSTTY_KEY_DELETE: .delete, GHOSTTY_KEY_PAGE_UP: .pageUp, GHOSTTY_KEY_PAGE_DOWN: .pageDown, GHOSTTY_KEY_ESCAPE: .escape, GHOSTTY_KEY_ENTER: .return, GHOSTTY_KEY_TAB: .tab, GHOSTTY_KEY_BACKSPACE: .delete, GHOSTTY_KEY_SPACE: .space, ]}