Update ghostty to v1.3.0 (#1142)

* Update ghostty to v1.3.0

* Add bell handling and AppleScript support

* Add zsh shell integration handoff test

* Fix Ghostty zsh integration handoff in cmux

* Add terminal keypress notification dismissal test

* Dismiss terminal notifications on keypress

* Address PR review feedback

* Tighten notification dismissal regression test

* Pin GhosttyKit checksum for latest ghostty
This commit is contained in:
Lawrence Chen 2026-03-09 21:32:54 -07:00 committed by GitHub
parent 55b619b538
commit dea60ea71c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1542 additions and 9 deletions

View file

@ -23,6 +23,7 @@
A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; };
A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; };
A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.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 */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; };
A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; };
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.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 */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.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 */ /* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase 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 = "<group>"; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; }; A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; };
A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = "<group>"; };
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; };
A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; };
A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; };
@ -235,6 +238,7 @@
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -284,6 +288,7 @@
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */, A5002000 /* THIRD_PARTY_LICENSES.md in Resources */,
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */,
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */, DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */,
A5001623 /* cmux.sdef in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -364,6 +369,7 @@
A5001541 /* PortScanner.swift */, A5001541 /* PortScanner.swift */,
A5001225 /* SocketControlSettings.swift */, A5001225 /* SocketControlSettings.swift */,
A5001600 /* SentryHelper.swift */, A5001600 /* SentryHelper.swift */,
A5001620 /* AppleScriptSupport.swift */,
A5001090 /* AppDelegate.swift */, A5001090 /* AppDelegate.swift */,
A5001091 /* NotificationsPage.swift */, A5001091 /* NotificationsPage.swift */,
A5001092 /* TerminalNotificationStore.swift */, A5001092 /* TerminalNotificationStore.swift */,
@ -415,6 +421,7 @@
C1ADE00001A1B2C3D4E5F719 /* claude */, C1ADE00001A1B2C3D4E5F719 /* claude */,
DA7A10CA710E000000000001 /* Localizable.xcstrings */, DA7A10CA710E000000000001 /* Localizable.xcstrings */,
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, DA7A10CA710E000000000002 /* InfoPlist.xcstrings */,
A5001622 /* cmux.sdef */,
); );
path = Resources; path = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
@ -631,6 +638,7 @@
A5001540 /* PortScanner.swift in Sources */, A5001540 /* PortScanner.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */,
A5001601 /* SentryHelper.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */,
A5001621 /* AppleScriptSupport.swift in Sources */,
A5001093 /* AppDelegate.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */,
A5001094 /* NotificationsPage.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */,
A5001095 /* TerminalNotificationStore.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */,

View file

@ -48,6 +48,10 @@
</array> </array>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>NSAppleScriptEnabled</key>
<true/>
<key>OSAScriptingDefinition</key>
<string>cmux.sdef</string>
<key>NSServices</key> <key>NSServices</key>
<array> <array>
<dict> <dict>

View file

@ -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": { "about.build": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {

192
Resources/cmux.sdef Normal file
View file

@ -0,0 +1,192 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">
<dictionary title="cmux Scripting Dictionary">
<suite name="cmux Suite" code="Cmux" description="cmux scripting support.">
<class name="application" code="capp" description="The cmux application.">
<cocoa class="NSApplication"/>
<property name="name" code="pnam" type="text" access="r" description="The name of the application."/>
<property name="frontmost" code="pisf" type="boolean" access="r" description="Is this the active application?">
<cocoa key="isActive"/>
</property>
<property name="front window" code="CMFW" type="window" access="r" description="The frontmost cmux window.">
<cocoa key="frontWindow"/>
</property>
<property name="version" code="vers" type="text" access="r" description="The version number of the application."/>
<responds-to command="perform action">
<cocoa method="handlePerformActionScriptCommand:"/>
</responds-to>
<responds-to command="new window">
<cocoa method="handleNewWindowScriptCommand:"/>
</responds-to>
<responds-to command="new tab">
<cocoa method="handleNewTabScriptCommand:"/>
</responds-to>
<responds-to command="quit">
<cocoa method="handleQuitScriptCommand:"/>
</responds-to>
<element type="window" access="r">
<cocoa key="scriptWindows"/>
</element>
<element type="terminal" access="r">
<cocoa key="terminals"/>
</element>
</class>
<class name="window" code="CMwn" plural="windows" description="A cmux window containing one or more workspaces.">
<cocoa class="CmuxScriptWindow"/>
<property name="id" code="ID " type="text" access="r" description="Stable ID for this window."/>
<property name="name" code="pnam" type="text" access="r" description="The title of the window.">
<cocoa key="title"/>
</property>
<property name="selected tab" code="CMsT" type="tab" access="r" description="The selected workspace in this window.">
<cocoa key="selectedTab"/>
</property>
<responds-to command="activate window">
<cocoa method="handleActivateWindowCommand:"/>
</responds-to>
<responds-to command="close window">
<cocoa method="handleCloseWindowCommand:"/>
</responds-to>
<element type="tab" access="r">
<cocoa key="tabs"/>
</element>
<element type="terminal" access="r">
<cocoa key="terminals"/>
</element>
</class>
<class name="tab" code="CMtb" plural="tabs" description="A cmux workspace.">
<cocoa class="CmuxScriptTab"/>
<property name="id" code="ID " type="text" access="r" description="Stable ID for this workspace."/>
<property name="name" code="pnam" type="text" access="r" description="The title of the workspace.">
<cocoa key="title"/>
</property>
<property name="index" code="pidx" type="integer" access="r" description="1-based index of this workspace in its window."/>
<property name="selected" code="CMsl" type="boolean" access="r" description="Whether this workspace is selected in its window."/>
<property name="focused terminal" code="CMfT" type="terminal" access="r" description="The currently focused terminal panel in this workspace.">
<cocoa key="focusedTerminal"/>
</property>
<responds-to command="select tab">
<cocoa method="handleSelectTabCommand:"/>
</responds-to>
<responds-to command="close tab">
<cocoa method="handleCloseTabCommand:"/>
</responds-to>
<element type="terminal" access="r">
<cocoa key="terminals"/>
</element>
</class>
<class name="terminal" code="CMtr" plural="terminals" description="An individual terminal panel.">
<cocoa class="CmuxScriptTerminal"/>
<property name="id" code="ID " type="text" access="r" description="Stable ID for this terminal panel."/>
<property name="name" code="pnam" type="text" access="r" description="Current terminal title.">
<cocoa key="title"/>
</property>
<property name="working directory" code="CMwd" type="text" access="r" description="Current working directory for the terminal process.">
<cocoa key="workingDirectory"/>
</property>
<responds-to command="split">
<cocoa method="handleSplitCommand:"/>
</responds-to>
<responds-to command="focus">
<cocoa method="handleFocusCommand:"/>
</responds-to>
<responds-to command="close">
<cocoa method="handleCloseCommand:"/>
</responds-to>
</class>
<enumeration name="split direction" code="CMSD" description="Direction for a new split.">
<enumerator name="right" code="GSrt" description="Split to the right."/>
<enumerator name="left" code="GSlf" description="Split to the left."/>
<enumerator name="down" code="GSdn" description="Split downward."/>
<enumerator name="up" code="GSup" description="Split upward."/>
</enumeration>
<command name="perform action" code="CmuxPfAc" description="Perform a Ghostty action string on a terminal.">
<direct-parameter type="text" description="The Ghostty action string."/>
<parameter name="on" code="CMoT" type="terminal" description="Target terminal.">
<cocoa key="on"/>
</parameter>
<result type="boolean" description="True when the action was performed."/>
</command>
<command name="new window" code="CmuxNWin" description="Create a new cmux window.">
<result type="window" description="The newly created window."/>
</command>
<command name="new tab" code="CmuxNTab" description="Create a new workspace.">
<parameter name="in" code="CMtW" type="window" optional="yes" description="Target window for the new workspace.">
<cocoa key="window"/>
</parameter>
<result type="tab" description="The newly created workspace."/>
</command>
<command name="split" code="CmuxSplt" description="Split a terminal in the given direction.">
<direct-parameter type="specifier" description="The terminal to split."/>
<parameter name="direction" code="CMpd" type="split direction" description="The direction to split.">
<cocoa key="direction"/>
</parameter>
<result type="terminal" description="The newly created terminal."/>
</command>
<command name="focus" code="CmuxFcus" description="Focus a terminal, bringing its window to the front.">
<direct-parameter type="specifier" description="The terminal to focus."/>
</command>
<command name="close" code="CmuxClos" description="Close a terminal.">
<direct-parameter type="specifier" description="The terminal to close."/>
</command>
<command name="activate window" code="CmuxAcWn" description="Activate a cmux window, bringing it to the front.">
<direct-parameter type="specifier" description="The window to activate."/>
</command>
<command name="select tab" code="CmuxSlTb" description="Select a workspace in its window.">
<direct-parameter type="specifier" description="The workspace to select."/>
</command>
<command name="close tab" code="CmuxClTb" description="Close a workspace.">
<direct-parameter type="specifier" description="The workspace to close."/>
</command>
<command name="close window" code="CmuxClWn" description="Close a window.">
<direct-parameter type="specifier" description="The window to close."/>
</command>
<command name="input text" code="CmuxInTx" description="Input text to a terminal as if it was pasted.">
<cocoa class="CmuxScriptInputTextCommand"/>
<direct-parameter type="text" description="The text to input."/>
<parameter name="to" code="CMiT" type="terminal" description="The terminal to input text to.">
<cocoa key="terminal"/>
</parameter>
</command>
</suite>
<suite name="Standard Suite" code="????" description="Common classes and commands for all applications.">
<command name="count" code="corecnte" description="Return the number of elements of a particular class within an object.">
<cocoa class="NSCountCommand"/>
<access-group identifier="*"/>
<direct-parameter type="specifier" requires-access="r" description="The objects to be counted."/>
<parameter name="each" code="kocl" type="type" optional="yes" description="The class of objects to be counted." hidden="yes">
<cocoa key="ObjectClass"/>
</parameter>
<result type="integer" description="The count."/>
</command>
<command name="exists" code="coredoex" description="Verify that an object exists.">
<cocoa class="NSExistsCommand"/>
<access-group identifier="*"/>
<direct-parameter type="any" requires-access="r" description="The object(s) to check."/>
<result type="boolean" description="Did the object(s) exist?"/>
</command>
<command name="quit" code="aevtquit" description="Quit the application.">
<cocoa class="NSQuitCommand"/>
</command>
</suite>
</dictionary>

View file

@ -13,9 +13,7 @@
# - CMUX_ZSH_ZDOTDIR (set by cmux when it overwrote a user-provided ZDOTDIR) # - CMUX_ZSH_ZDOTDIR (set by cmux when it overwrote a user-provided ZDOTDIR)
# - unset (zsh treats unset ZDOTDIR as $HOME) # - unset (zsh treats unset ZDOTDIR as $HOME)
builtin typeset _cmux_had_ghostty_zdotdir=0
if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then
_cmux_had_ghostty_zdotdir=1
builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR"
builtin unset GHOSTTY_ZSH_ZDOTDIR builtin unset GHOSTTY_ZSH_ZDOTDIR
elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then
@ -33,9 +31,10 @@ fi
if [[ -o interactive ]]; then if [[ -o interactive ]]; then
# We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's # We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's
# zsh integration if available. # 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. # We can't rely on GHOSTTY_ZSH_ZDOTDIR here because Ghostty's own zsh
if [[ "$_cmux_had_ghostty_zdotdir" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then # 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" builtin typeset _cmux_ghostty="$GHOSTTY_RESOURCES_DIR/shell-integration/zsh/ghostty-integration"
[[ -r "$_cmux_ghostty" ]] && builtin source -- "$_cmux_ghostty" [[ -r "$_cmux_ghostty" ]] && builtin source -- "$_cmux_ghostty"
fi fi
@ -47,5 +46,5 @@ fi
fi fi
fi fi
builtin unset _cmux_file _cmux_ghostty _cmux_integ _cmux_had_ghostty_zdotdir builtin unset _cmux_file _cmux_ghostty _cmux_integ
} }

View file

@ -1490,6 +1490,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
} }
} }
struct ScriptableMainWindowState {
let windowId: UUID
let tabManager: TabManager
let window: NSWindow?
}
struct SessionDisplayGeometry { struct SessionDisplayGeometry {
let displayID: UInt32? let displayID: UInt32?
let frame: CGRect let frame: CGRect
@ -3414,6 +3420,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
windowForMainWindowId(windowId) windowForMainWindowId(windowId)
} }
func scriptableMainWindows() -> [ScriptableMainWindowState] {
var results: [ScriptableMainWindowState] = []
var seen: Set<UUID> = []
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?) { private func markCommandPaletteOpenRequested(for window: NSWindow?) {
guard let window, guard let window,
let windowId = mainWindowId(for: window) else { return } let windowId = mainWindowId(for: window) else { return }

View file

@ -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<UUID> = []
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
}
}
}

View file

@ -708,6 +708,7 @@ class GhosttyApp {
private let backgroundLogLock = NSLock() private let backgroundLogLock = NSLock()
private var backgroundLogSequence: UInt64 = 0 private var backgroundLogSequence: UInt64 = 0
private var appObservers: [NSObjectProtocol] = [] private var appObservers: [NSObjectProtocol] = []
private var bellAudioSound: NSSound?
private var backgroundEventCounter: UInt64 = 0 private var backgroundEventCounter: UInt64 = 0
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
private var defaultBackgroundScopeSource: String = "initialize" private var defaultBackgroundScopeSource: String = "initialize"
@ -1524,6 +1525,75 @@ class GhosttyApp {
return found && enabled 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<Int8>?
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( private func applyDefaultBackground(
color: NSColor, color: NSColor,
opacity: Double, 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 { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
let soft = action.action.reload_config.soft let soft = action.action.reload_config.soft
logThemeAction("reload request target=app soft=\(soft)") logThemeAction("reload request target=app soft=\(soft)")
@ -1797,6 +1874,11 @@ class GhosttyApp {
guard let tabManager = AppDelegate.shared?.tabManager else { return false } guard let tabManager = AppDelegate.shared?.tabManager else { return false }
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil 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: case GHOSTTY_ACTION_GOTO_SPLIT:
guard let tabId = surfaceView.tabId, guard let tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id, let surfaceId = surfaceView.terminalSurface?.id,
@ -2739,6 +2821,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
?? "/bin/zsh" ?? "/bin/zsh"
let shellName = URL(fileURLWithPath: shell).lastPathComponent let shellName = URL(fileURLWithPath: shell).lastPathComponent
if shellName == "zsh" { if shellName == "zsh" {
if GhosttyApp.shared.shellIntegrationMode() != "none" {
env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
}
let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil) let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil)
?? getenv("ZDOTDIR").map { String(cString: $0) } ?? getenv("ZDOTDIR").map { String(cString: $0) }
?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil) ?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil)
@ -4332,6 +4417,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.keyDown(with: event) super.keyDown(with: event)
return return
} }
if let terminalSurface {
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
tabId: terminalSurface.tabId,
surfaceId: terminalSurface.id
)
}
if event.keyCode != 53 { if event.keyCode != 53 {
endFindEscapeSuppression() endFindEscapeSuppression()
} }

View file

@ -831,6 +831,54 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase {
XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed") XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed")
XCTAssertTrue(app.tabManager === manager) 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 @MainActor
@ -7476,6 +7524,24 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase {
return event 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? { private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? {
hostedView.subviews hostedView.subviews
.compactMap { $0 as? NSScrollView } .compactMap { $0 as? NSScrollView }
@ -7556,6 +7622,76 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase {
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) 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)
}
} }

View file

@ -1459,3 +1459,83 @@ final class GhosttyMouseFocusTests: XCTestCase {
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path])) 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)
}
}

View file

@ -12,9 +12,11 @@ When we change the fork, update this document and the parent submodule SHA.
## Current fork changes ## 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 ### 1) OSC 99 (kitty) notification parser
- Commit: `4713b7e23` (Add OSC 99 notification parser) - Commit: `a2252e7a9` (Add OSC 99 notification parser)
- Files: - Files:
- `src/terminal/osc.zig` - `src/terminal/osc.zig`
- `src/terminal/osc/parsers.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 ### 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: - Files:
- `src/renderer/generic.zig` - `src/renderer/generic.zig`
- Summary: - Summary:
- Restarts the CVDisplayLink when `setMacOSDisplayID` updates the current CGDisplay. - 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. - 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 ## Merge conflict notes
These files change frequently upstream; be careful when rebasing the fork: These files change frequently upstream; be careful when rebasing the fork:

@ -1 +1 @@
Subproject commit 7dd589824d4c9bda8265355718800cccaf7189a0 Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec

View file

@ -463,6 +463,12 @@ typedef struct {
// Config types // Config types
// config.Path
typedef struct {
const char* path;
bool optional;
} ghostty_config_path_s;
// config.Color // config.Color
typedef struct { typedef struct {
uint8_t r; uint8_t r;

View file

@ -2,3 +2,4 @@
# Update this file in a reviewed PR whenever the ghostty submodule SHA changes. # Update this file in a reviewed PR whenever the ghostty submodule SHA changes.
# Format: <ghostty_sha> <sha256> # Format: <ghostty_sha> <sha256>
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d