diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index a975a0e7..4111742b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; + A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; @@ -94,6 +95,7 @@ A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; + A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -165,6 +167,7 @@ A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; + A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; @@ -235,6 +238,7 @@ A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -284,6 +288,7 @@ A5002000 /* THIRD_PARTY_LICENSES.md in Resources */, DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */, + A5001623 /* cmux.sdef in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -364,6 +369,7 @@ A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, A5001600 /* SentryHelper.swift */, + A5001620 /* AppleScriptSupport.swift */, A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -415,6 +421,7 @@ C1ADE00001A1B2C3D4E5F719 /* claude */, DA7A10CA710E000000000001 /* Localizable.xcstrings */, DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, + A5001622 /* cmux.sdef */, ); path = Resources; sourceTree = ""; @@ -631,6 +638,7 @@ A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, + A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, diff --git a/Resources/Info.plist b/Resources/Info.plist index 00d9fa86..f1beb4f9 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -48,6 +48,10 @@ NSPrincipalClass NSApplication + NSAppleScriptEnabled + + OSAScriptingDefinition + cmux.sdef NSServices diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d568c9b3..8a729692 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -115,6 +115,193 @@ } } }, + "applescript.error.disabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AppleScript is disabled by the macos-applescript configuration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "macos-applescript の設定で AppleScript は無効になっています。" + } + } + } + }, + "applescript.error.failedToCreateSplit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to create split." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割の作成に失敗しました。" + } + } + } + }, + "applescript.error.failedToCreateWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to create window." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウの作成に失敗しました。" + } + } + } + }, + "applescript.error.failedToCreateWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to create workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの作成に失敗しました。" + } + } + } + }, + "applescript.error.missingAction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing action string." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクション文字列がありません。" + } + } + } + }, + "applescript.error.missingInputText": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing input text." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力するテキストがありません。" + } + } + } + }, + "applescript.error.missingSplitDirection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or unknown split direction." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割方向がないか、不明です。" + } + } + } + }, + "applescript.error.missingTerminalTarget": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing terminal target." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象のターミナルがありません。" + } + } + } + }, + "applescript.error.terminalUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルはもう利用できません。" + } + } + } + }, + "applescript.error.windowUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウはもう利用できません。" + } + } + } + }, + "applescript.error.workspaceUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースはもう利用できません。" + } + } + } + }, "about.build": { "extractionState": "manual", "localizations": { diff --git a/Resources/cmux.sdef b/Resources/cmux.sdef new file mode 100644 index 00000000..b55edd4b --- /dev/null +++ b/Resources/cmux.sdef @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/shell-integration/.zshenv b/Resources/shell-integration/.zshenv index 68925a2f..74241671 100644 --- a/Resources/shell-integration/.zshenv +++ b/Resources/shell-integration/.zshenv @@ -13,9 +13,7 @@ # - CMUX_ZSH_ZDOTDIR (set by cmux when it overwrote a user-provided ZDOTDIR) # - unset (zsh treats unset ZDOTDIR as $HOME) -builtin typeset _cmux_had_ghostty_zdotdir=0 if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then - _cmux_had_ghostty_zdotdir=1 builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" builtin unset GHOSTTY_ZSH_ZDOTDIR elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then @@ -33,9 +31,10 @@ fi if [[ -o interactive ]]; then # We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's # zsh integration if available. - # Guard on GHOSTTY_ZSH_ZDOTDIR being set by Ghostty. When users configure - # shell-integration=none, Ghostty does not set this and we must skip. - if [[ "$_cmux_had_ghostty_zdotdir" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then + # + # We can't rely on GHOSTTY_ZSH_ZDOTDIR here because Ghostty's own zsh + # bootstrap unsets it before chaining into this cmux wrapper. + if [[ "${CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then builtin typeset _cmux_ghostty="$GHOSTTY_RESOURCES_DIR/shell-integration/zsh/ghostty-integration" [[ -r "$_cmux_ghostty" ]] && builtin source -- "$_cmux_ghostty" fi @@ -47,5 +46,5 @@ fi fi fi - builtin unset _cmux_file _cmux_ghostty _cmux_integ _cmux_had_ghostty_zdotdir + builtin unset _cmux_file _cmux_ghostty _cmux_integ } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 358a5dce..550650e8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1490,6 +1490,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + struct ScriptableMainWindowState { + let windowId: UUID + let tabManager: TabManager + let window: NSWindow? + } + struct SessionDisplayGeometry { let displayID: UInt32? let frame: CGRect @@ -3414,6 +3420,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowForMainWindowId(windowId) } + func scriptableMainWindows() -> [ScriptableMainWindowState] { + var results: [ScriptableMainWindowState] = [] + var seen: Set = [] + + for window in NSApp.orderedWindows { + guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue } + guard seen.insert(context.windowId).inserted else { continue } + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + ) + } + + let remaining = mainWindowContexts.values + .sorted { $0.windowId.uuidString < $1.windowId.uuidString } + .filter { seen.insert($0.windowId).inserted } + + for context in remaining { + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + ) + } + + return results + } + + func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { + return nil + } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + } + + func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? { + guard let context = contextContainingTabId(tabId) else { return nil } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + } + + @discardableResult + func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { + guard let state = scriptableMainWindow(windowId: windowId), + let window = state.window else { + return false + } + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } + return true + } + + @discardableResult + func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? { + guard let state = scriptableMainWindow(windowId: windowId) else { return nil } + if shouldBringToFront, let window = state.window { + setActiveMainWindow(window) + bringToFront(window) + } + let workspace = state.tabManager.addWorkspace( + workingDirectory: workingDirectory, + select: shouldBringToFront + ) + return workspace.id + } + private func markCommandPaletteOpenRequested(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } diff --git a/Sources/AppleScriptSupport.swift b/Sources/AppleScriptSupport.swift new file mode 100644 index 00000000..640750d5 --- /dev/null +++ b/Sources/AppleScriptSupport.swift @@ -0,0 +1,705 @@ +import AppKit + +private enum AppleScriptStrings { + static let disabled = String( + localized: "applescript.error.disabled", + defaultValue: "AppleScript is disabled by the macos-applescript configuration." + ) + static let missingAction = String( + localized: "applescript.error.missingAction", + defaultValue: "Missing action string." + ) + static let missingInputText = String( + localized: "applescript.error.missingInputText", + defaultValue: "Missing input text." + ) + static let missingTerminalTarget = String( + localized: "applescript.error.missingTerminalTarget", + defaultValue: "Missing terminal target." + ) + static let missingSplitDirection = String( + localized: "applescript.error.missingSplitDirection", + defaultValue: "Missing or unknown split direction." + ) + static let windowUnavailable = String( + localized: "applescript.error.windowUnavailable", + defaultValue: "Window is no longer available." + ) + static let workspaceUnavailable = String( + localized: "applescript.error.workspaceUnavailable", + defaultValue: "Workspace is no longer available." + ) + static let terminalUnavailable = String( + localized: "applescript.error.terminalUnavailable", + defaultValue: "Terminal is no longer available." + ) + static let failedToCreateWindow = String( + localized: "applescript.error.failedToCreateWindow", + defaultValue: "Failed to create window." + ) + static let failedToCreateWorkspace = String( + localized: "applescript.error.failedToCreateWorkspace", + defaultValue: "Failed to create workspace." + ) + static let failedToCreateSplit = String( + localized: "applescript.error.failedToCreateSplit", + defaultValue: "Failed to create split." + ) +} + +private extension String { + var fourCharCode: UInt32 { + utf8.reduce(0) { ($0 << 8) + UInt32($1) } + } +} + +private extension Workspace { + func scriptingTerminalPanels() -> [TerminalPanel] { + var results: [TerminalPanel] = [] + var seen: Set = [] + + for panelId in sidebarOrderedPanelIds() { + guard seen.insert(panelId).inserted, + let terminal = terminalPanel(for: panelId) else { + continue + } + results.append(terminal) + } + + let remaining = panels.values + .compactMap { $0 as? TerminalPanel } + .sorted { $0.id.uuidString < $1.id.uuidString } + + for terminal in remaining where seen.insert(terminal.id).inserted { + results.append(terminal) + } + + return results + } +} + +@MainActor +extension NSApplication { + var isAppleScriptEnabled: Bool { + GhosttyApp.shared.appleScriptAutomationEnabled() + } + + @discardableResult + func validateScript(command: NSScriptCommand) -> Bool { + guard isAppleScriptEnabled else { + command.scriptErrorNumber = errAEEventNotPermitted + command.scriptErrorString = AppleScriptStrings.disabled + return false + } + + return true + } + + @objc(scriptWindows) + var scriptWindows: [ScriptWindow] { + guard isAppleScriptEnabled, + let appDelegate = AppDelegate.shared else { + return [] + } + return appDelegate.scriptableMainWindows().map { ScriptWindow(windowId: $0.windowId) } + } + + @objc(frontWindow) + var frontWindow: ScriptWindow? { + scriptWindows.first + } + + @objc(valueInScriptWindowsWithUniqueID:) + func valueInScriptWindows(uniqueID: String) -> ScriptWindow? { + guard isAppleScriptEnabled, + let windowId = UUID(uuidString: uniqueID), + let appDelegate = AppDelegate.shared, + appDelegate.scriptableMainWindow(windowId: windowId) != nil else { + return nil + } + return ScriptWindow(windowId: windowId) + } + + @objc(terminals) + var terminals: [ScriptTerminal] { + guard isAppleScriptEnabled, + let appDelegate = AppDelegate.shared else { + return [] + } + + return appDelegate.scriptableMainWindows() + .flatMap { state in + state.tabManager.tabs.flatMap { workspace in + workspace.scriptingTerminalPanels().map { + ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id) + } + } + } + } + + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard isAppleScriptEnabled, + let terminalId = UUID(uuidString: uniqueID), + let appDelegate = AppDelegate.shared else { + return nil + } + + for state in appDelegate.scriptableMainWindows() { + for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil { + return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId) + } + } + + return nil + } + + @objc(handlePerformActionScriptCommand:) + func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> NSNumber? { + guard validateScript(command: command) else { return nil } + + guard let action = command.directParameter as? String else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = AppleScriptStrings.missingAction + return nil + } + + guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = AppleScriptStrings.missingTerminalTarget + return nil + } + + return NSNumber(value: terminal.perform(action: action)) + } + + @objc(handleNewWindowScriptCommand:) + func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> ScriptWindow? { + guard validateScript(command: command) else { return nil } + + guard let appDelegate = AppDelegate.shared else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.failedToCreateWindow + return nil + } + + let windowId = appDelegate.createMainWindow() + return ScriptWindow(windowId: windowId) + } + + @objc(handleNewTabScriptCommand:) + func handleNewTabScriptCommand(_ command: NSScriptCommand) -> ScriptTab? { + guard validateScript(command: command) else { return nil } + + guard let appDelegate = AppDelegate.shared else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace + return nil + } + + if let targetWindow = command.evaluatedArguments?["window"] as? ScriptWindow { + guard let workspaceId = appDelegate.addWorkspace(windowId: targetWindow.windowId, bringToFront: false) else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace + return nil + } + return ScriptTab(windowId: targetWindow.windowId, tabId: workspaceId) + } + + if let frontWindow = scriptWindows.first, + let workspaceId = appDelegate.addWorkspace(windowId: frontWindow.windowId, bringToFront: false) { + return ScriptTab(windowId: frontWindow.windowId, tabId: workspaceId) + } + + let windowId = appDelegate.createMainWindow() + return ScriptWindow(windowId: windowId).selectedTab + } + + @objc(handleQuitScriptCommand:) + func handleQuitScriptCommand(_ command: NSScriptCommand) { + guard validateScript(command: command) else { return } + terminate(nil) + } +} + +@MainActor +@objc(CmuxScriptWindow) +final class ScriptWindow: NSObject { + let windowId: UUID + + init(windowId: UUID) { + self.windowId = windowId + } + + private var state: AppDelegate.ScriptableMainWindowState? { + AppDelegate.shared?.scriptableMainWindow(windowId: windowId) + } + + @objc(id) + var idValue: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return windowId.uuidString + } + + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled, + let state else { + return "" + } + + let windowTitle = state.window?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !windowTitle.isEmpty { + return windowTitle + } + + return state.tabManager.selectedWorkspace?.title ?? "" + } + + @objc(tabs) + var tabs: [ScriptTab] { + guard NSApp.isAppleScriptEnabled, + let state else { + return [] + } + return state.tabManager.tabs.map { ScriptTab(windowId: windowId, tabId: $0.id) } + } + + @objc(selectedTab) + var selectedTab: ScriptTab? { + guard NSApp.isAppleScriptEnabled, + let selectedId = state?.tabManager.selectedTabId else { + return nil + } + return ScriptTab(windowId: windowId, tabId: selectedId) + } + + @objc(terminals) + var terminals: [ScriptTerminal] { + guard NSApp.isAppleScriptEnabled, + let state else { + return [] + } + return state.tabManager.tabs.flatMap { workspace in + workspace.scriptingTerminalPanels().map { + ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id) + } + } + } + + @objc(valueInTabsWithUniqueID:) + func valueInTabs(uniqueID: String) -> ScriptTab? { + guard NSApp.isAppleScriptEnabled, + let tabId = UUID(uuidString: uniqueID), + let state, + state.tabManager.tabs.contains(where: { $0.id == tabId }) else { + return nil + } + return ScriptTab(windowId: windowId, tabId: tabId) + } + + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard NSApp.isAppleScriptEnabled, + let terminalId = UUID(uuidString: uniqueID), + let state else { + return nil + } + + for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil { + return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId) + } + + return nil + } + + @objc(handleActivateWindowCommand:) + func handleActivateWindow(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard AppDelegate.shared?.focusScriptableMainWindow(windowId: windowId, bringToFront: true) == true else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.windowUnavailable + return nil + } + + return nil + } + + @objc(handleCloseWindowCommand:) + func handleCloseWindow(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let window = state?.window else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.windowUnavailable + return nil + } + + window.performClose(nil) + return nil + } + + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled, + let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: appClassDescription, + containerSpecifier: nil, + key: "scriptWindows", + uniqueID: windowId.uuidString + ) + } +} + +@MainActor +@objc(CmuxScriptTab) +final class ScriptTab: NSObject { + let windowId: UUID + let tabId: UUID + + init(windowId: UUID, tabId: UUID) { + self.windowId = windowId + self.tabId = tabId + } + + private var state: AppDelegate.ScriptableMainWindowState? { + AppDelegate.shared?.scriptableMainWindow(windowId: windowId) + } + + private var workspace: Workspace? { + state?.tabManager.tabs.first(where: { $0.id == tabId }) + } + + private var window: ScriptWindow { + ScriptWindow(windowId: windowId) + } + + @objc(id) + var idValue: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return tabId.uuidString + } + + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return workspace?.title ?? "" + } + + @objc(index) + var index: Int { + guard NSApp.isAppleScriptEnabled, + let state, + let idx = state.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + return 0 + } + return idx + 1 + } + + @objc(selected) + var selected: Bool { + guard NSApp.isAppleScriptEnabled else { return false } + return state?.tabManager.selectedTabId == tabId + } + + @objc(focusedTerminal) + var focusedTerminal: ScriptTerminal? { + guard NSApp.isAppleScriptEnabled, + let terminalId = workspace?.focusedTerminalPanel?.id else { + return nil + } + return ScriptTerminal(workspaceId: tabId, terminalId: terminalId) + } + + @objc(terminals) + var terminals: [ScriptTerminal] { + guard NSApp.isAppleScriptEnabled, + let workspace else { + return [] + } + return workspace.scriptingTerminalPanels().map { + ScriptTerminal(workspaceId: tabId, terminalId: $0.id) + } + } + + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard NSApp.isAppleScriptEnabled, + let workspace, + let terminalId = UUID(uuidString: uniqueID), + workspace.terminalPanel(for: terminalId) != nil else { + return nil + } + return ScriptTerminal(workspaceId: tabId, terminalId: terminalId) + } + + @objc(handleSelectTabCommand:) + func handleSelectTab(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let state, + let workspace else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.workspaceUnavailable + return nil + } + + state.tabManager.selectWorkspace(workspace) + return nil + } + + @objc(handleCloseTabCommand:) + func handleCloseTab(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let state, + let workspace else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.workspaceUnavailable + return nil + } + + if state.tabManager.tabs.count > 1 { + state.tabManager.closeWorkspace(workspace) + return nil + } + + guard let window = state.window else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.windowUnavailable + return nil + } + + window.performClose(nil) + return nil + } + + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled, + let windowClassDescription = window.classDescription as? NSScriptClassDescription, + let windowSpecifier = window.objectSpecifier else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: windowClassDescription, + containerSpecifier: windowSpecifier, + key: "tabs", + uniqueID: tabId.uuidString + ) + } +} + +@MainActor +@objc(CmuxScriptTerminal) +final class ScriptTerminal: NSObject { + let workspaceId: UUID + let terminalId: UUID + + init(workspaceId: UUID, terminalId: UUID) { + self.workspaceId = workspaceId + self.terminalId = terminalId + } + + private var state: AppDelegate.ScriptableMainWindowState? { + AppDelegate.shared?.scriptableMainWindowForTab(workspaceId) + } + + private var workspace: Workspace? { + state?.tabManager.tabs.first(where: { $0.id == workspaceId }) + } + + private var terminal: TerminalPanel? { + workspace?.terminalPanel(for: terminalId) + } + + @objc(id) + var stableID: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return terminalId.uuidString + } + + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return terminal?.displayTitle ?? "" + } + + @objc(workingDirectory) + var workingDirectory: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return terminal?.directory ?? "" + } + + func input(text: String) -> Bool { + guard NSApp.isAppleScriptEnabled, + let terminal else { + return false + } + terminal.sendText(text) + return true + } + + func perform(action: String) -> Bool { + guard NSApp.isAppleScriptEnabled else { return false } + return terminal?.performBindingAction(action) ?? false + } + + @objc(handleSplitCommand:) + func handleSplit(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32, + let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = AppleScriptStrings.missingSplitDirection + return nil + } + + guard let state, + let workspace, + terminal != nil else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + + guard let newPanelId = state.tabManager.newSplit(tabId: workspaceId, surfaceId: terminalId, direction: direction), + workspace.terminalPanel(for: newPanelId) != nil else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.failedToCreateSplit + return nil + } + + return ScriptTerminal(workspaceId: workspaceId, terminalId: newPanelId) + } + + @objc(handleFocusCommand:) + func handleFocus(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let state, + let workspace, + terminal != nil else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + + if let app = AppDelegate.shared { + _ = app.focusScriptableMainWindow(windowId: state.windowId, bringToFront: true) + } + state.tabManager.selectWorkspace(workspace) + workspace.focusPanel(terminalId) + return nil + } + + @objc(handleCloseCommand:) + func handleClose(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let state, + let workspace, + terminal != nil else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + + if workspace.panels.count == 1 { + if state.tabManager.tabs.count > 1 { + state.tabManager.closeWorkspace(workspace) + return nil + } + + guard let window = state.window else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.windowUnavailable + return nil + } + + window.performClose(nil) + return nil + } + + guard workspace.closePanel(terminalId, force: true) else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspaceId, surfaceId: terminalId) + return nil + } + + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled, + let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: appClassDescription, + containerSpecifier: nil, + key: "terminals", + uniqueID: terminalId.uuidString + ) + } +} + +@MainActor +@objc(CmuxScriptInputTextCommand) +final class ScriptInputTextCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard NSApp.validateScript(command: self) else { return nil } + + guard let text = directParameter as? String else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = AppleScriptStrings.missingInputText + return nil + } + + guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = AppleScriptStrings.missingTerminalTarget + return nil + } + + guard terminal.input(text: text) else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + return nil + } +} + +private enum ScriptSplitDirection { + case right + case left + case down + case up + + init?(code: UInt32) { + switch code { + case "GSrt".fourCharCode: self = .right + case "GSlf".fourCharCode: self = .left + case "GSdn".fourCharCode: self = .down + case "GSup".fourCharCode: self = .up + default: return nil + } + } + + var splitDirection: SplitDirection { + switch self { + case .right: return .right + case .left: return .left + case .down: return .down + case .up: return .up + } + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 88a7f314..61e89c18 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -708,6 +708,7 @@ class GhosttyApp { private let backgroundLogLock = NSLock() private var backgroundLogSequence: UInt64 = 0 private var appObservers: [NSObjectProtocol] = [] + private var bellAudioSound: NSSound? private var backgroundEventCounter: UInt64 = 0 private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped private var defaultBackgroundScopeSource: String = "initialize" @@ -1524,6 +1525,75 @@ class GhosttyApp { return found && enabled } + func appleScriptAutomationEnabled() -> Bool { + guard let config else { return false } + var enabled = false + let key = "macos-applescript" + _ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8))) + return enabled + } + + fileprivate func shellIntegrationMode() -> String { + guard let config else { return "detect" } + var value: UnsafePointer? + let key = "shell-integration" + guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), + let value else { + return "detect" + } + return String(cString: value) + } + + private func bellFeatures() -> CUnsignedInt { + guard let config else { return 0 } + var features: CUnsignedInt = 0 + let key = "bell-features" + _ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8))) + return features + } + + private func bellAudioPath() -> String? { + guard let config else { return nil } + var value = ghostty_config_path_s() + let key = "bell-audio-path" + guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), + let rawPath = value.path else { + return nil + } + let path = String(cString: rawPath) + return path.isEmpty ? nil : path + } + + private func bellAudioVolume() -> Float { + guard let config else { return 0.5 } + var value: Double = 0.5 + let key = "bell-audio-volume" + _ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) + return Float(min(1.0, max(0.0, value))) + } + + private func ringBell() { + let features = bellFeatures() + + if (features & (1 << 0)) != 0 { + NSSound.beep() + } + + if (features & (1 << 1)) != 0, + let path = bellAudioPath(), + let sound = NSSound(contentsOfFile: path, byReference: false) { + sound.volume = bellAudioVolume() + bellAudioSound = sound + if !sound.play() { + bellAudioSound = nil + } + } + + if (features & (1 << 2)) != 0 { + NSApp.requestUserAttention(.informationalRequest) + } + } + private func applyDefaultBackground( color: NSColor, opacity: Double, @@ -1690,6 +1760,13 @@ class GhosttyApp { } } + if action.tag == GHOSTTY_ACTION_RING_BELL { + performOnMain { + self.ringBell() + } + return true + } + if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG { let soft = action.action.reload_config.soft logThemeAction("reload request target=app soft=\(soft)") @@ -1797,6 +1874,11 @@ class GhosttyApp { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } + case GHOSTTY_ACTION_RING_BELL: + performOnMain { + self.ringBell() + } + return true case GHOSTTY_ACTION_GOTO_SPLIT: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, @@ -2739,6 +2821,9 @@ final class TerminalSurface: Identifiable, ObservableObject { ?? "/bin/zsh" let shellName = URL(fileURLWithPath: shell).lastPathComponent if shellName == "zsh" { + if GhosttyApp.shared.shellIntegrationMode() != "none" { + env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" + } let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil) ?? getenv("ZDOTDIR").map { String(cString: $0) } ?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil) @@ -4332,6 +4417,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.keyDown(with: event) return } + if let terminalSurface { + AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( + tabId: terminalSurface.tabId, + surfaceId: terminalSurface.id + ) + } if event.keyCode != 53 { endFindEscapeSuppression() } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 367b62e3..403914da 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -831,6 +831,54 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed") XCTAssertTrue(app.tabManager === manager) } + + func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + windowA.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(app.tabManager === managerA) + + let originalSelectedA = managerA.selectedTabId + let originalSelectedB = managerB.selectedTabId + let originalTabCountB = managerB.tabs.count + + let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false) + + XCTAssertNotNil(createdWorkspaceId) + XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing") + XCTAssertEqual(managerA.selectedTabId, originalSelectedA) + XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab") + XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1) + XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId })) + } } @MainActor @@ -7476,6 +7524,24 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { return event } + private func makeKeyEvent(characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { + fatalError("Failed to create key event") + } + return event + } + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? { hostedView.subviews .compactMap { $0 as? NSScrollView } @@ -7556,6 +7622,76 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) } + + func testTerminalKeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let window = makeWindow() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + window.orderOut(nil) + } + + guard let workspace = manager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected an initial focused terminal panel") + return + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + + guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else { + XCTFail("Expected terminal surface view") + return + } + + GhosttySurfaceScrollView.resetFlashCounts() + AppFocusState.overrideIsFocused = true + XCTAssertTrue(window.makeFirstResponder(surfaceView)) + + store.addNotification( + tabId: workspace.id, + surfaceId: terminalPanel.id, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + + let event = makeKeyEvent(characters: "", keyCode: 122, window: window) + surfaceView.keyDown(with: event) + let drained = expectation(description: "flash drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) + } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index e229b761..26d3a789 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1459,3 +1459,83 @@ final class GhosttyMouseFocusTests: XCTestCase { XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path])) } } + +final class ZshShellIntegrationHandoffTests: XCTestCase { + func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws { + let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true) + + XCTAssertTrue(output.contains("PRECMD=1"), output) + XCTAssertTrue(output.contains("PREEXEC=1"), output) + XCTAssertTrue(output.contains("PRECMDS=_ghostty_precmd"), output) + } + + func testGhosttyPromptHooksDoNotLoadWithoutCmuxHandoffFlag() throws { + let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: false) + + XCTAssertTrue(output.contains("PRECMD=0"), output) + XCTAssertTrue(output.contains("PREEXEC=0"), output) + } + + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let userZdotdir = root.appendingPathComponent("zdotdir") + try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true) + try "\n".write(to: userZdotdir.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) + + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let cmuxZdotdir = repoRoot.appendingPathComponent("Resources/shell-integration") + let ghosttyResources = repoRoot.appendingPathComponent("ghostty/src") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = [ + "-i", + "-c", + "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " + + "print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " + + "PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\"" + ] + process.environment = [ + "HOME": root.path, + "TERM": "xterm-256color", + "SHELL": "/bin/zsh", + "USER": NSUserName(), + "ZDOTDIR": cmuxZdotdir.path, + "CMUX_ZSH_ZDOTDIR": userZdotdir.path, + "CMUX_SHELL_INTEGRATION": "0", + "GHOSTTY_RESOURCES_DIR": ghosttyResources.path, + ] + if cmuxLoadGhosttyIntegration { + process.environment?["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" + } + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + let deadline = Date().addingTimeInterval(5) + while process.isRunning && Date() < deadline { + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + if process.isRunning { + process.terminate() + process.waitUntilExit() + XCTFail("Timed out waiting for zsh to exit") + } + + let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + XCTAssertEqual(process.terminationStatus, 0, error) + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index e5ed8988..c98a76c3 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,9 +12,11 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes +Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 2026. + ### 1) OSC 99 (kitty) notification parser -- Commit: `4713b7e23` (Add OSC 99 notification parser) +- Commit: `a2252e7a9` (Add OSC 99 notification parser) - Files: - `src/terminal/osc.zig` - `src/terminal/osc/parsers.zig` @@ -24,13 +26,49 @@ When we change the fork, update this document and the parent submodule SHA. ### 2) macOS display link restart on display changes -- Commit: `7c2562cbe` (macos: restart display link after display ID change) +- Commit: `c07e6c5a5` (macos: restart display link after display ID change) - Files: - `src/renderer/generic.zig` - Summary: - Restarts the CVDisplayLink when `setMacOSDisplayID` updates the current CGDisplay. - Prevents a rare state where vsync is "running" but no callbacks arrive, which can look like a frozen surface until focus/occlusion changes. +### 3) Keyboard copy mode selection C API + +- Commit: `a50579bd5` (Add C API for keyboard copy mode selection) +- Files: + - `src/Surface.zig` + - `src/apprt/embedded.zig` +- Summary: + - Restores `ghostty_surface_select_cursor_cell` and `ghostty_surface_clear_selection`. + - Keeps cmux keyboard copy mode working against the refreshed Ghostty base. + +### 4) macOS resize stale-frame mitigation + +Sections 3 and 4 are grouped by feature, not by commit order. The fork branch HEAD is the +section 3 copy-mode commit, even though the section 4 resize commits were applied earlier. + +- Commits: + - `769bbf7a9` (macos: reduce transient blank/scaled frames during resize) + - `9efcdfdf8` (macos: keep top-left gravity for stale-frame replay) +- Files: + - `pkg/macos/animation.zig` + - `src/Surface.zig` + - `src/apprt/embedded.zig` + - `src/renderer/Metal.zig` + - `src/renderer/generic.zig` + - `src/renderer/metal/IOSurfaceLayer.zig` +- Summary: + - Replays the last rendered frame during resize and keeps its geometry anchored correctly. + - Reduces transient blank or scaled frames while a macOS window is being resized. + +## Upstreamed fork changes + +### cursor-click-to-move respects OSC 133 click-to-move + +- Was local in the fork as `10a585754`. +- Landed upstream as `bb646926f`, so it is no longer carried as a fork-only patch. + ## Merge conflict notes These files change frequently upstream; be careful when rebasing the fork: diff --git a/ghostty b/ghostty index 7dd58982..a50579bd 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 7dd589824d4c9bda8265355718800cccaf7189a0 +Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec diff --git a/ghostty.h b/ghostty.h index b54e84f1..585564d7 100644 --- a/ghostty.h +++ b/ghostty.h @@ -463,6 +463,12 @@ typedef struct { // Config types +// config.Path +typedef struct { + const char* path; + bool optional; +} ghostty_config_path_s; + // config.Color typedef struct { uint8_t r; diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 29794d12..8ab36d3b 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -2,3 +2,4 @@ # Update this file in a reviewed PR whenever the ghostty submodule SHA changes. # Format: 7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 +a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d