diff --git a/.gitmodules b/.gitmodules index 51853e8565..7919b0cd02 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "ghostty"] path = ghostty - url = https://github.com/manaflow-ai/ghostty.git - branch = main + url = https://github.com/mdsakalu/ghostty.git + branch = zmx-termio-backend [submodule "homebrew-cmux"] path = homebrew-cmux url = https://github.com/manaflow-ai/homebrew-cmux.git diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index a975a0e7a5..4b9c9209b5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; }; + A5001640 /* ZmxSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* ZmxSupport.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; @@ -204,6 +205,7 @@ A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; + A5001641 /* ZmxSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZmxSupport.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; @@ -395,6 +397,7 @@ A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, A5001611 /* SessionPersistence.swift */, + A5001641 /* ZmxSupport.swift */, ); path = Sources; sourceTree = ""; @@ -662,6 +665,7 @@ A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, + A5001640 /* ZmxSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d568c9b30b..38f81247eb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -53589,6 +53589,142 @@ } } }, + "settings.zmx.persistence.enable.subtitle.disabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminals start fresh on each launch." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルは起動のたびに新しく開始されます。" + } + } + } + }, + "settings.zmx.persistence.enable.subtitle.enabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal sessions survive app restart via zmx." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルセッションはzmxによりアプリ再起動後も保持されます。" + } + } + } + }, + "settings.zmx.persistence.enable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable zmx Session Persistence" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "zmxセッション永続化を有効にする" + } + } + } + }, + "settings.zmx.persistence.killOnClose.subtitle.disabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "zmx sessions are preserved when a workspace is closed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じてもzmxセッションは保持されます。" + } + } + } + }, + "settings.zmx.persistence.killOnClose.subtitle.enabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Closing a workspace terminates its zmx sessions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じると、そのzmxセッションも終了します。" + } + } + } + }, + "settings.zmx.persistence.killOnClose.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kill Sessions on Workspace Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じるときにセッションを終了する" + } + } + } + }, + "settings.zmx.persistence.note.requirements": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Requires zmx to be installed and on PATH." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "zmxがインストールされ、PATHに含まれている必要があります。" + } + } + } + }, + "settings.zmx.persistence.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "zmx Session Persistence" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "zmxセッション永続化" + } + } + } + }, "settings.section.automation": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 28b80d6f5f..0ca634e249 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1638,6 +1638,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func applicationDidFinishLaunching(_ notification: Notification) { + // Don't leak parent zmx session into child terminals. + // Each cmux terminal gets its own zmx session via the ghostty zmx backend config. + unsetenv("ZMX_SESSION") + let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch @@ -8725,6 +8729,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // is removed when the last window closes. persistWindowGeometry(from: window) guard let removed = unregisterMainWindowContext(for: window) else { return } + if !isTerminatingApp { + for workspace in removed.tabManager.tabs { + workspace.killZmxSessions() + } + } commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) @@ -8798,11 +8807,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent contextContainingTabId(tabId)?.tabManager } - func closeMainWindowContainingTabId(_ tabId: UUID) { - guard let context = contextContainingTabId(tabId) else { return } + @discardableResult + func closeMainWindowContainingTabId(_ tabId: UUID) -> Bool { + guard let context = contextContainingTabId(tabId) else { return false } let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - window?.performClose(nil) + guard let window else { return false } + window.performClose(nil) + return true } @discardableResult diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4e2139e451..8f5e292710 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2023,6 +2023,10 @@ final class TerminalSurface: Identifiable, ObservableObject { private(set) var tabId: UUID /// Port ordinal for CMUX_PORT range assignment var portOrdinal: Int = 0 + /// zmx session name to pass to ghostty C API for session persistence + let zmxSessionName: String? + /// Whether to create the zmx session if it doesn't exist (true = create, false = attach only) + let zmxCreate: Bool /// Snapshotted once per app session so all workspaces use consistent values private static let sessionPortBase: Int = { let val = UserDefaults.standard.integer(forKey: "cmuxPortBase") @@ -2047,6 +2051,10 @@ final class TerminalSurface: Identifiable, ObservableObject { private let maxPendingTextBytes = 1_048_576 private var backgroundSurfaceStartQueued = false private var surfaceCallbackContext: Unmanaged? + private var initialPresentRetryWorkItem: DispatchWorkItem? + private var initialPresentRetriesRemaining = 0 + private static let initialPresentRetryDelay: TimeInterval = 0.08 + private static let initialPresentRetryLimit = 12 private enum PortalLifecycleState: String { case live case closing @@ -2096,7 +2104,9 @@ final class TerminalSurface: Identifiable, ObservableObject { context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?, workingDirectory: String? = nil, - additionalEnvironment: [String: String] = [:] + additionalEnvironment: [String: String] = [:], + zmxSessionName: String? = nil, + zmxCreate: Bool = true ) { self.id = UUID() self.tabId = tabId @@ -2104,6 +2114,8 @@ final class TerminalSurface: Identifiable, ObservableObject { self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) self.additionalEnvironment = additionalEnvironment + self.zmxSessionName = zmxSessionName + self.zmxCreate = zmxCreate // 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. @@ -2147,6 +2159,7 @@ final class TerminalSurface: Identifiable, ObservableObject { func beginPortalCloseLifecycle(reason: String) { guard portalLifecycleState != .closed else { return } guard portalLifecycleState != .closing else { return } + cancelInitialPresentRetries() portalLifecycleState = .closing portalLifecycleGeneration &+= 1 #if DEBUG @@ -2160,6 +2173,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private func markPortalLifecycleClosed(reason: String) { guard portalLifecycleState != .closed else { return } + cancelInitialPresentRetries() portalLifecycleState = .closed portalLifecycleGeneration &+= 1 #if DEBUG @@ -2253,6 +2267,74 @@ final class TerminalSurface: Identifiable, ObservableObject { abs(lhs - rhs) <= epsilon } + private func cancelInitialPresentRetries() { + initialPresentRetryWorkItem?.cancel() + initialPresentRetryWorkItem = nil + initialPresentRetriesRemaining = 0 + } + + private func scheduleInitialPresentRetries(reason: String, resetBudget: Bool = false) { + guard portalLifecycleState == .live else { return } + guard attachedView != nil else { return } + if resetBudget || initialPresentRetriesRemaining <= 0 { + initialPresentRetriesRemaining = Self.initialPresentRetryLimit + } + armInitialPresentRetry(reason: reason) + } + + private func armInitialPresentRetry(reason: String) { + guard initialPresentRetriesRemaining > 0 else { return } + initialPresentRetryWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.runInitialPresentRetry(reason: reason) + } + initialPresentRetryWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.initialPresentRetryDelay, + execute: workItem + ) + } + + private func runInitialPresentRetry(reason: String) { + initialPresentRetryWorkItem = nil + guard portalLifecycleState == .live else { return } + guard let view = attachedView, + view.window != nil, + view.bounds.width > 0, + view.bounds.height > 0 else { + return + } + guard surface != nil else { return } + + if view.layer?.contents != nil { + cancelInitialPresentRetries() + return + } + + initialPresentRetriesRemaining -= 1 + + // Re-read surface after forceRefreshSurface (may be invalidated by reparent/teardown) + view.forceRefreshSurface() + guard let currentSurface = surface else { + cancelInitialPresentRetries() + return + } + + // Use occlusion toggle + refresh to trigger Ghostty's renderer wakeup. + // Do NOT call ghostty_surface_draw directly — rely on Ghostty wakeups to + // avoid typing lag (per coding guidelines). + ghostty_surface_set_occlusion(currentSurface, false) + ghostty_surface_set_occlusion(currentSurface, true) + ghostty_surface_refresh(currentSurface) + + if view.layer?.contents != nil || initialPresentRetriesRemaining <= 0 { + cancelInitialPresentRetries() + return + } + + armInitialPresentRetry(reason: reason) + } + func attachToView(_ view: GhosttyNSView) { #if DEBUG dlog( @@ -2471,6 +2553,20 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + // Start from an explicit disabled state so inherited config templates + // never leak another surface's zmx session into a fresh panel. + surfaceConfig.zmx_session = nil + surfaceConfig.zmx_create = false + + // zmx session: pass session name and create flag to ghostty + var zmxCStr: UnsafeMutablePointer? + defer { zmxCStr.flatMap { free($0) } } + if let name = zmxSessionName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + zmxCStr = strdup(name) + surfaceConfig.zmx_session = UnsafePointer(zmxCStr) + surfaceConfig.zmx_create = zmxCreate + } + let createSurface = { [self] in if !envVars.isEmpty { let envVarsCount = envVars.count @@ -2564,6 +2660,7 @@ final class TerminalSurface: Identifiable, ObservableObject { // transition nudges the renderer. view.forceRefreshSurface() ghostty_surface_refresh(createdSurface) + scheduleInitialPresentRetries(reason: "createSurface", resetBudget: true) #if DEBUG let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { @@ -2674,6 +2771,17 @@ final class TerminalSurface: Identifiable, ObservableObject { view.forceRefreshSurface() guard let surface = self.surface else { return } ghostty_surface_refresh(surface) + if view.layer?.contents == nil { + // Freshly attached surfaces can get stuck until a later hide/show cycle + // toggles Ghostty's visible state. Recreate that pulse locally so + // Ghostty's renderer wakes up and produces the first frame. + ghostty_surface_set_occlusion(surface, false) + ghostty_surface_set_occlusion(surface, true) + ghostty_surface_refresh(surface) + scheduleInitialPresentRetries(reason: reason) + } else { + cancelInitialPresentRetries() + } } func applyWindowBackgroundIfActive() { @@ -2732,6 +2840,7 @@ final class TerminalSurface: Identifiable, ObservableObject { guard let self else { return } self.backgroundSurfaceStartQueued = false guard self.surface == nil, let view = self.attachedView else { return } + guard view.window != nil else { return } #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime #endif @@ -3178,6 +3287,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() updateSurfaceSize() + terminalSurface?.forceRefresh(reason: "surface.viewDidMoveToWindow") applySurfaceBackground() applySurfaceColorScheme(force: true) GhosttyApp.shared.synchronizeThemeWithAppearance( diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index d4bb68b3ab..b96c55fada 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -85,14 +85,18 @@ final class TerminalPanel: Panel, ObservableObject { configTemplate: ghostty_surface_config_s? = nil, workingDirectory: String? = nil, additionalEnvironment: [String: String] = [:], - portOrdinal: Int = 0 + portOrdinal: Int = 0, + zmxSessionName: String? = nil, + zmxCreate: Bool = true ) { let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, workingDirectory: workingDirectory, - additionalEnvironment: additionalEnvironment + additionalEnvironment: additionalEnvironment, + zmxSessionName: zmxSessionName, + zmxCreate: zmxCreate ) surface.portOrdinal = portOrdinal self.init(workspaceId: workspaceId, surface: surface) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 53eb995eba..2521c0b2e0 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -253,6 +253,8 @@ struct SessionPanelSnapshot: Codable, Sendable { var terminal: SessionTerminalPanelSnapshot? var browser: SessionBrowserPanelSnapshot? var markdown: SessionMarkdownPanelSnapshot? + /// zmx session name for this terminal panel (nil if not using zmx). + var zmxSessionName: String? } enum SessionSplitOrientation: String, Codable, Sendable { @@ -339,6 +341,8 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var logEntries: [SessionLogEntrySnapshot] var progress: SessionProgressSnapshot? var gitBranch: SessionGitBranchSnapshot? + /// Stable identifier for zmx session name derivation (persists across sessions). + var zmxStableId: String? } struct SessionTabManagerSnapshot: Codable, Sendable { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0920d58824..7697871ccf 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1217,15 +1217,23 @@ class TabManager: ObservableObject { } func closeWorkspace(_ workspace: Workspace) { - guard tabs.count > 1 else { return } guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) clearInitialWorkspaceGitProbe(workspaceId: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) unwireClosedBrowserTracking(for: workspace) - workspace.teardownAllPanels() + workspace.killZmxSessions() + + if tabs.count <= 1 { + if AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id) != true { + workspace.teardownAllPanels() + tabs.remove(at: index) + } + return + } + workspace.teardownAllPanels() tabs.remove(at: index) if selectedTabId == workspace.id { @@ -1425,12 +1433,7 @@ class TabManager: ObservableObject { ) { return } - if tabs.count <= 1 { - // Last workspace in this window: close the window (Cmd+Shift+W behavior). - AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id) - } else { - closeWorkspace(workspace) - } + closeWorkspace(workspace) } private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) { @@ -1484,12 +1487,7 @@ class TabManager: ObservableObject { } } - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id) - if willCloseWindow { - AppDelegate.shared?.closeMainWindowContainingTabId(tab.id) - } else { - closeWorkspace(tab) - } + closeWorkspace(tab) return } @@ -1596,17 +1594,7 @@ class TabManager: ObservableObject { // Child-exit on the last panel should collapse the workspace, matching explicit close // semantics (and close the window when it was the last workspace). if tab.panels.count <= 1 { - if tabs.count <= 1 { - if let app = AppDelegate.shared { - app.notificationStore?.clearNotifications(forTabId: tabId) - app.closeMainWindowContainingTabId(tabId) - } else { - // Headless/test fallback when no AppDelegate window context exists. - closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) - } - } else { - closeWorkspace(tab) - } + closeWorkspace(tab) return } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1b649937eb..800e7181b2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -162,13 +162,19 @@ extension Workspace { statusEntries: statusSnapshots, logEntries: logSnapshots, progress: progressSnapshot, - gitBranch: gitBranchSnapshot + gitBranch: gitBranchSnapshot, + zmxStableId: zmxStableId ) } func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + // Restore zmx stable ID for session name derivation + if let stableId = snapshot.zmxStableId { + zmxStableId = stableId + } + let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) if !normalizedCurrentDirectory.isEmpty { currentDirectory = normalizedCurrentDirectory @@ -231,6 +237,9 @@ extension Workspace { } else { scheduleFocusReconcile() } + + // Rehydrate zmx panel index counter to prevent session name collisions + rehydrateZmxPanelIndex() } private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { @@ -362,7 +371,8 @@ extension Workspace { ttyName: ttyName, terminal: terminalSnapshot, browser: browserSnapshot, - markdown: markdownSnapshot + markdown: markdownSnapshot, + zmxSessionName: zmxSessionNames[panelId] ) } @@ -499,7 +509,11 @@ extension Workspace { inPane: paneId, focus: false, workingDirectory: workingDirectory, - startupEnvironment: replayEnvironment + startupEnvironment: replayEnvironment, + // Avoid socket probing on the main actor during restore. The zmx backend + // already handles attach-or-create and falls back to exec when unavailable. + zmxSessionName: ZmxPersistenceSettings.isEnabled ? snapshot.zmxSessionName : nil, + zmxCreate: true ) else { return nil } @@ -946,6 +960,14 @@ final class Workspace: Identifiable, ObservableObject { /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? + // MARK: - zmx Session Persistence (stored properties) + /// Stable identifier for zmx session name derivation (persists across sessions). + var zmxStableId: String? + /// Panel ID → zmx session name mapping. + var zmxSessionNames: [UUID: String] = [:] + /// Next panel index counter for deterministic session naming. + private var zmxNextPanelIndex: Int = 0 + // Closing tabs mutates split layout immediately; terminal views handle their own AppKit // layout/size synchronization. @@ -1144,16 +1166,19 @@ final class Workspace: Identifiable, ObservableObject { let welcomeTabIds = bonsplitController.allTabIds // Create initial terminal panel + let initialZmxName = zmxAutoAssignSessionName() let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + zmxSessionName: initialZmxName ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) + if let initialZmxName { zmxSessionNames[terminalPanel.id] = initialZmxName } // Create initial tab in bonsplit and store the mapping var initialTabId: TabID? @@ -1253,6 +1278,7 @@ final class Workspace: Identifiable, ObservableObject { struct DetachedSurfaceTransfer { let panelId: UUID let panel: any Panel + let zmxSessionName: String? let title: String let icon: String? let iconImageData: Data? @@ -1978,16 +2004,19 @@ final class Workspace: Identifiable, ObservableObject { #endif // Create the new terminal panel. + let splitZmxName = zmxAutoAssignSessionName() let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: splitWorkingDirectory, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + zmxSessionName: splitZmxName ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + if let splitZmxName { zmxSessionNames[newPanel.id] = splitZmxName } // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). @@ -2013,6 +2042,7 @@ final class Workspace: Identifiable, ObservableObject { panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + zmxSessionNames.removeValue(forKey: newPanel.id) return nil } @@ -2049,12 +2079,18 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, focus: Bool? = nil, workingDirectory: String? = nil, - startupEnvironment: [String: String] = [:] + startupEnvironment: [String: String] = [:], + zmxSessionName: String? = nil, + zmxCreate: Bool = true ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + // Resolve zmx session name: explicit name from restore, or auto-assign for fresh terminals. + // Must be resolved before panel creation because it's passed in the ghostty surface config. + let resolvedZmxName = zmxSessionName ?? zmxAutoAssignSessionName() + // Create new terminal panel let newPanel = TerminalPanel( workspaceId: id, @@ -2062,11 +2098,16 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, workingDirectory: workingDirectory, additionalEnvironment: startupEnvironment, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + zmxSessionName: resolvedZmxName, + zmxCreate: zmxCreate ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + if let resolvedZmxName { + zmxSessionNames[newPanel.id] = resolvedZmxName + } // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( @@ -2080,6 +2121,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + zmxSessionNames.removeValue(forKey: newPanel.id) return nil } @@ -2878,6 +2920,11 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadPanelIds.remove(detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) } + if let zmxSessionName = detached.zmxSessionName { + zmxSessionNames[detached.panelId] = zmxSessionName + } else { + zmxSessionNames.removeValue(forKey: detached.panelId) + } guard let newTabId = bonsplitController.createTab( title: detached.title, @@ -2898,6 +2945,7 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadPanelIds.remove(detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) + zmxSessionNames.removeValue(forKey: detached.panelId) #if DEBUG dlog( "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + @@ -3303,15 +3351,18 @@ final class Workspace: Identifiable, ObservableObject { preferredPanelId: focusedPanelId, inPane: bonsplitController.focusedPaneId ) + let replZmxName = zmxAutoAssignSessionName() let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: inheritedConfig, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + zmxSessionName: replZmxName ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + if let replZmxName { zmxSessionNames[newPanel.id] = replZmxName } // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -4054,6 +4105,7 @@ extension Workspace: BonsplitDelegate { pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, + zmxSessionName: zmxSessionNames[panelId], title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, @@ -4084,6 +4136,7 @@ extension Workspace: BonsplitDelegate { manualUnreadMarkedAt.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) + zmxSessionNames.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) @@ -4264,6 +4317,7 @@ extension Workspace: BonsplitDelegate { panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) + zmxSessionNames.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) } @@ -4375,16 +4429,19 @@ extension Workspace: BonsplitDelegate { // This avoids an extra create+close tab churn that can transiently render an // empty pane during drag-to-split of a single-tab pane. let inheritedConfig = inheritedTerminalConfig(inPane: originalPane) + let dragZmxName = zmxAutoAssignSessionName() let replacementPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + zmxSessionName: dragZmxName ) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) + if let dragZmxName { zmxSessionNames[replacementPanel.id] = dragZmxName } surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( @@ -4441,16 +4498,19 @@ extension Workspace: BonsplitDelegate { preferredPanelId: sourcePanelId, inPane: originalPane ) + let autoSplitZmxName = zmxAutoAssignSessionName() let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + zmxSessionName: autoSplitZmxName ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + if let autoSplitZmxName { zmxSessionNames[newPanel.id] = autoSplitZmxName } guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -4463,6 +4523,7 @@ extension Workspace: BonsplitDelegate { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + zmxSessionNames.removeValue(forKey: newPanel.id) return } @@ -4550,4 +4611,36 @@ extension Workspace: BonsplitDelegate { } // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. + + // MARK: - zmx Session Persistence (methods) + + /// Auto-generate a zmx session name for a new terminal (if zmx persistence is enabled). + /// Returns nil if zmx is disabled or unavailable. + func zmxAutoAssignSessionName() -> String? { + guard ZmxPersistenceSettings.isEnabled, ZmxSessionProbe.isZmxAvailable() else { return nil } + let stableId = zmxStableId ?? id.uuidString + if zmxStableId == nil { zmxStableId = stableId } + let name = ZmxSessionNaming.sessionName(stableId: stableId, panelIndex: zmxNextPanelIndex) + zmxNextPanelIndex += 1 + return name + } + + /// Rehydrate zmxNextPanelIndex from existing session names to prevent collisions. + func rehydrateZmxPanelIndex() { + var maxIndex = -1 + for name in zmxSessionNames.values { + if let idx = ZmxSessionNaming.parseIndex(from: name), idx > maxIndex { + maxIndex = idx + } + } + zmxNextPanelIndex = maxIndex + 1 + } + + /// Kill all zmx sessions owned by this workspace. + func killZmxSessions() { + guard ZmxPersistenceSettings.killOnWorkspaceClose else { return } + for name in zmxSessionNames.values { + ZmxSessionProbe.killSession(name) + } + } } diff --git a/Sources/ZmxSupport.swift b/Sources/ZmxSupport.swift new file mode 100644 index 0000000000..1d1e779d1f --- /dev/null +++ b/Sources/ZmxSupport.swift @@ -0,0 +1,218 @@ +import Foundation + +// MARK: - Settings + +/// UserDefaults-backed zmx persistence settings. +enum ZmxPersistenceSettings { + static let enabledKey = "zmxPersistenceEnabled" + static let killOnCloseKey = "zmxKillOnWorkspaceClose" + + static var isEnabled: Bool { + UserDefaults.standard.bool(forKey: enabledKey) + } + + static var killOnWorkspaceClose: Bool { + UserDefaults.standard.object(forKey: killOnCloseKey) as? Bool ?? true + } +} + +// MARK: - Session Naming + +/// Deterministic zmx session name derivation from workspace stable ID. +enum ZmxSessionNaming { + /// Generate a session name: "cmux-{8-char-hash}-{index}" + /// Must stay under 46 characters (zmx IPC limit). + static func sessionName(stableId: String, panelIndex: Int) -> String { + let hash = stableId.replacingOccurrences(of: "-", with: "").prefix(8) + return "cmux-\(hash)-\(panelIndex)" + } + + /// Extract the panel index from a zmx session name. + static func parseIndex(from sessionName: String) -> Int? { + guard let lastDash = sessionName.lastIndex(of: "-") else { return nil } + let suffix = sessionName[sessionName.index(after: lastDash)...] + return Int(suffix) + } +} + +// MARK: - Session Probing + +/// Non-blocking zmx daemon session probing over Unix sockets. +enum ZmxSessionProbe { + private static func setNoSigPipe(fd: Int32) -> Bool { +#if os(macOS) + var noSigPipe: Int32 = 1 + let result = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout.size) + ) + } + return result == 0 +#else + _ = fd + return true +#endif + } + + private static func writeAll(fd: Int32, bytes: [UInt8]) -> Bool { + bytes.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + var written = 0 + while written < rawBuffer.count { + let result = Darwin.write(fd, baseAddress.advanced(by: written), rawBuffer.count - written) + guard result > 0 else { return false } + written += result + } + return true + } + } + + private static func socketPath(for sessionName: String) -> String { + "\(socketDir())/\(sessionName)" + } + + /// Resolve the zmx socket directory. + static func socketDir() -> String { + if let dir = ProcessInfo.processInfo.environment["ZMX_DIR"] { + return dir + } + if let xdg = ProcessInfo.processInfo.environment["XDG_RUNTIME_DIR"] { + return "\(xdg)/zmx" + } + let tmpdir = ProcessInfo.processInfo.environment["TMPDIR"] ?? "/tmp" + return "\(tmpdir)/zmx-\(getuid())" + } + + /// Check if a zmx session is alive by probing its Unix socket. + /// Non-blocking connect + poll with 200ms timeout. + static func isSessionAlive(_ sessionName: String) -> Bool { + let socketPath = socketPath(for: sessionName) + + // sun_path limit is typically 104 bytes on macOS + guard socketPath.utf8.count < 104 else { return false } + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + + // Set non-blocking + let flags = fcntl(fd, F_GETFL, 0) + guard flags >= 0 else { return false } + guard fcntl(fd, F_SETFL, flags | O_NONBLOCK) >= 0 else { return false } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathMax = MemoryLayout.size(ofValue: addr.sun_path) - 1 + socketPath.withCString { cstr in + withUnsafeMutableBytes(of: &addr.sun_path) { buf in + let dest = buf.baseAddress!.assumingMemoryBound(to: CChar.self) + _ = strncpy(dest, cstr, pathMax) + } + } + + let connectResult = withUnsafePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + + if connectResult == 0 { return true } + guard errno == EINPROGRESS else { return false } + + // Poll for connection + var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + let pollResult = poll(&pfd, 1, 200) + guard pollResult > 0 else { return false } + + // Check SO_ERROR + var soError: Int32 = 0 + var soLen = socklen_t(MemoryLayout.size) + guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &soLen) == 0 else { return false } + return soError == 0 + } + + /// Check if the zmx binary is available on PATH (including common install locations). + static func isZmxAvailable() -> Bool { + // Check hardcoded common locations first + let commonPaths = [ + "/opt/homebrew/bin/zmx", + "/usr/local/bin/zmx", + ] + for path in commonPaths { + if FileManager.default.isExecutableFile(atPath: path) { + return true + } + } + + // Fall back to PATH scan + guard let pathEnv = ProcessInfo.processInfo.environment["PATH"] else { return false } + let dirs = pathEnv.split(separator: ":").map(String.init) + for dir in dirs { + let full = "\(dir)/zmx" + if FileManager.default.isExecutableFile(atPath: full) { + return true + } + } + return false + } + + /// Kill a zmx session by sending a Kill IPC message (tag=5) via Unix socket. + static func killSession(_ sessionName: String) { + let sessionName = sessionName + DispatchQueue.global(qos: .utility).async { + killSessionSync(sessionName) + } + } + + private static func killSessionSync(_ sessionName: String) { + let socketPath = socketPath(for: sessionName) + + guard socketPath.utf8.count < 104 else { return } + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return } + defer { close(fd) } + guard setNoSigPipe(fd: fd) else { return } + + // Use non-blocking connect with timeout to avoid hanging on wedged daemons + let flags = fcntl(fd, F_GETFL, 0) + guard flags >= 0 else { return } + guard fcntl(fd, F_SETFL, flags | O_NONBLOCK) >= 0 else { return } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathMax = MemoryLayout.size(ofValue: addr.sun_path) - 1 + socketPath.withCString { cstr in + withUnsafeMutableBytes(of: &addr.sun_path) { buf in + let dest = buf.baseAddress!.assumingMemoryBound(to: CChar.self) + _ = strncpy(dest, cstr, pathMax) + } + } + + let connectResult = withUnsafePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + + if connectResult != 0 { + guard errno == EINPROGRESS else { return } + // Wait for connect with 500ms timeout + var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + guard poll(&pfd, 1, 500) > 0 else { return } + var soError: Int32 = 0 + var soLen = socklen_t(MemoryLayout.size) + guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &soLen) == 0, + soError == 0 else { return } + } + + // Match the daemon's packed header layout as it is emitted by Zig at runtime: + // tag byte, 3 bytes of padding, then a 4-byte little-endian payload length. + let header: [UInt8] = [5, 0, 0, 0, 0, 0, 0, 0] + guard writeAll(fd: fd, bytes: header) else { return } + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 99ea9f8fc2..42647ab95e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2860,6 +2860,8 @@ struct SettingsView: View { @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + @AppStorage(ZmxPersistenceSettings.enabledKey) private var zmxPersistenceEnabled = false + @AppStorage(ZmxPersistenceSettings.killOnCloseKey) private var zmxKillOnWorkspaceClose = true @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) @@ -3716,6 +3718,39 @@ struct SettingsView: View { SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) } + SettingsSectionHeader(title: String(localized: "settings.zmx.persistence.title", defaultValue: "zmx Session Persistence")) + SettingsCard { + SettingsCardRow( + String(localized: "settings.zmx.persistence.enable.title", defaultValue: "Enable zmx Session Persistence"), + subtitle: zmxPersistenceEnabled + ? String(localized: "settings.zmx.persistence.enable.subtitle.enabled", defaultValue: "Terminal sessions survive app restart via zmx.") + : String(localized: "settings.zmx.persistence.enable.subtitle.disabled", defaultValue: "Terminals start fresh on each launch.") + ) { + Toggle("", isOn: $zmxPersistenceEnabled) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.zmx.persistence.killOnClose.title", defaultValue: "Kill Sessions on Workspace Close"), + subtitle: zmxKillOnWorkspaceClose + ? String(localized: "settings.zmx.persistence.killOnClose.subtitle.enabled", defaultValue: "Closing a workspace terminates its zmx sessions.") + : String(localized: "settings.zmx.persistence.killOnClose.subtitle.disabled", defaultValue: "zmx sessions are preserved when a workspace is closed.") + ) { + Toggle("", isOn: $zmxKillOnWorkspaceClose) + .labelsHidden() + .controlSize(.small) + .disabled(!zmxPersistenceEnabled) + } + + if !ZmxSessionProbe.isZmxAvailable() { + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.zmx.persistence.note.requirements", defaultValue: "Requires zmx to be installed and on PATH.")) + } + } + SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) SettingsCard { SettingsPickerRow( @@ -4158,6 +4193,8 @@ struct SettingsView: View { notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + zmxPersistenceEnabled = false + zmxKillOnWorkspaceClose = true commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints diff --git a/ghostty b/ghostty index 7dd589824d..e0ebec4d77 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 7dd589824d4c9bda8265355718800cccaf7189a0 +Subproject commit e0ebec4d77594dd506a238edb7cc8256ef309f19 diff --git a/ghostty.h b/ghostty.h index b54e84f124..f2e853cad0 100644 --- a/ghostty.h +++ b/ghostty.h @@ -437,6 +437,13 @@ typedef enum { GHOSTTY_SURFACE_CONTEXT_SPLIT = 2, } ghostty_surface_context_e; +typedef enum { + GHOSTTY_SURFACE_IO_EXEC = 0, + GHOSTTY_SURFACE_IO_MANUAL = 1, +} ghostty_surface_io_mode_e; + +typedef void (*ghostty_io_write_cb)(void*, const char*, uintptr_t); + typedef struct { ghostty_platform_e platform_tag; ghostty_platform_u platform; @@ -450,6 +457,12 @@ typedef struct { const char* initial_input; bool wait_after_command; ghostty_surface_context_e context; + ghostty_surface_io_mode_e io_mode; + ghostty_io_write_cb io_write_cb; + void* io_write_userdata; + const char* zmx_session; + bool zmx_create; + bool zmx_mode; } ghostty_surface_config_s; typedef struct { @@ -509,6 +522,15 @@ typedef struct { ghostty_quick_terminal_size_s secondary; } ghostty_config_quick_terminal_size_s; +// config.Fullscreen +typedef enum { + GHOSTTY_CONFIG_FULLSCREEN_FALSE, + GHOSTTY_CONFIG_FULLSCREEN_TRUE, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, +} ghostty_config_fullscreen_e; + // apprt.Target.Key typedef enum { GHOSTTY_TARGET_APP, @@ -577,9 +599,9 @@ typedef enum { // apprt.action.Fullscreen typedef enum { GHOSTTY_FULLSCREEN_NATIVE, - GHOSTTY_FULLSCREEN_NON_NATIVE, - GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, - GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH, } ghostty_action_fullscreen_e; // apprt.action.FloatWindow @@ -709,7 +731,7 @@ typedef struct { // renderer.Health typedef enum { - GHOSTTY_RENDERER_HEALTH_OK, + GHOSTTY_RENDERER_HEALTH_HEALTHY, GHOSTTY_RENDERER_HEALTH_UNHEALTHY, } ghostty_action_renderer_health_e; @@ -904,6 +926,7 @@ typedef enum { GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, + GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, } ghostty_action_tag_e; typedef union { @@ -1079,6 +1102,7 @@ bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_binding_flags_e*); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); +void ghostty_surface_process_output(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); bool ghostty_surface_mouse_button(ghostty_surface_t, ghostty_input_mouse_state_e, @@ -1107,9 +1131,9 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char*, void*, bool); -bool ghostty_surface_has_selection(ghostty_surface_t); -bool ghostty_surface_select_cursor_cell(ghostty_surface_t); bool ghostty_surface_clear_selection(ghostty_surface_t); +bool ghostty_surface_select_cursor_cell(ghostty_surface_t); +bool ghostty_surface_has_selection(ghostty_surface_t); bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); bool ghostty_surface_read_text(ghostty_surface_t, ghostty_selection_s,