macOS: fix non control keys are not working for AppleScript (#13205)

`send key` only works for control keys like `enter` currently; this adds
(fixes) the support for other keys listed as available. Found by
@paaloeye in #13180

The core of this fix is relying on `UCKeyTranslate` to get the
corresponding character and code point from a key code using
`KeyboardLayout.character(for:modifiers:)`.

ScriptKeyEventCommand now respects `macos-option-as-alt`, and attach
`text`, `unshifted_codepoint` and `consumed_mods` under the same
condition as a manual input events like in `performKeyEquivalent` and
`localEventKeyDown`.

## AI Disclosure

Claude did the heavy lifting, I reviewed and rephrased some of the
comments it generated. And ofc reviewed and tested myself.
This commit is contained in:
Mitchell Hashimoto
2026-08-27 09:33:24 -07:00
committed by GitHub
8 changed files with 228 additions and 76 deletions

View File

@@ -226,7 +226,7 @@
</parameter>
</command>
<command name="send key" code="GhstSKey" description="Send a keyboard event to a terminal.">
<command name="send key" code="GhstSKey" description="Send a keyboard event to a terminal. Keys are translated once through the current keyboard layout. Dead keys do not compose with subsequent keys.">
<cocoa class="GhosttyScriptKeyEventCommand"/>
<direct-parameter type="text" description="The key name (e.g. &quot;enter&quot;, &quot;a&quot;, &quot;space&quot;)."/>
<parameter name="action" code="GKeA" type="input action" optional="yes" description="Press or release (default: press).">

View File

@@ -33,7 +33,9 @@ final class ScriptKeyEventCommand: NSScriptCommand {
do {
keyEvent = try Self.parse(
directParameter: directParameter,
evaluatedArguments: evaluatedArguments)
evaluatedArguments: evaluatedArguments,
translationMods: surface.keyTranslationMods,
)
} catch ArgumentError.missingKey {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing key name."
@@ -108,9 +110,10 @@ extension ScriptKeyEventCommand {
}
return Ghostty.Input.KeyEvent(
key: key,
synthesizing: key,
action: action,
mods: mods,
translationMods: translationMods(mods),
)
}
}

View File

@@ -240,6 +240,66 @@ extension Ghostty.Input {
}
}
extension Ghostty.Input.KeyEvent {
/// Create a translated key event for programmatic input (e.g. AppleScript).
///
/// - Parameters:
/// - key: The key being pressed or released.
/// - action: The key action.
/// - mods: The full set of modifiers for the event.
/// - translationMods: The subset of `mods` that participates in text
/// translation. Use `Surface.keyTranslationMods(_:)` so that
/// configuration such as `macos-option-as-alt` is honored.
///
/// - Note: Translation is a single stateless pass through the keyboard layout.
/// A key that starts a dead-key sequence (e.g. option+E on a US layout)
/// produces its standalone character or nothing.
@MainActor
init(
synthesizing key: Ghostty.Input.Key,
action: Ghostty.Input.Action,
mods: Ghostty.Input.Mods,
translationMods: Ghostty.Input.Mods
) {
let keyCode = key.keyCode
// Control never contributes to the translation of text,
// matching `NSEvent.ghosttyCharacters`.
let text: String?
if action == .release {
// We don't need to attach text to a release key event,
// as real NSEvents don't carry them in most cases.
text = nil
} else {
text = keyCode
.flatMap {
KeyboardLayout.character(
for: $0,
modifiers: translationMods.nsFlags.subtracting(.control))
}
.flatMap { String($0).keyEventText }
}
// The unshifted codepoint ignores all modifiers. Control characters are
// reported as no codepoint (0) so that Ghostty encodes such keys from
// the key enum instead.
let unshiftedCodepoint = keyCode
.flatMap { KeyboardLayout.character(for: $0, modifiers: []) }
.flatMap { String($0).keyEventText }?
.unicodeScalars.first?.value ?? 0
self.init(
key: key,
action: action,
text: text,
mods: mods,
// Same as `NSEvent.ghosttyKeyEvent`
consumedMods: translationMods.subtracting([.ctrl, .super]),
unshiftedCodepoint: unshiftedCodepoint
)
}
}
// MARK: Ghostty.Input.Action
extension Ghostty.Input {

View File

@@ -58,6 +58,17 @@ extension Ghostty {
}
}
/// Returns the modifiers that participate in text translation for key
/// events on this surface. This honors configuration such as
/// `macos-option-as-alt`, which may exclude option from translation.
///
/// - Parameter mods: The full set of modifiers for the key event.
/// - Returns: The subset of `mods` to use for keyboard layout translation.
@MainActor
func keyTranslationMods(_ mods: Input.Mods) -> Input.Mods {
Input.Mods(cMods: ghostty_surface_key_translation_mods(surface, mods.cMods))
}
/// Send a key event to the terminal.
///
/// This sends the full key event including modifiers, action type, and text to the terminal.

View File

@@ -1484,12 +1484,7 @@ extension Ghostty {
var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
key_ev.composing = composing
// Control characters are encoded by Ghostty itself so that the
// physical key and its modifiers remain available to protocols
// such as the Kitty keyboard protocol.
if let text,
!text.isEmpty,
!text.startsWithASCIIControlCharacter {
if let text = text?.keyEventText {
return text.withCString { ptr in
key_ev.text = ptr
return ghostty_surface_key(surface, key_ev)

View File

@@ -5,6 +5,17 @@ extension String {
return scalar.value < 0x20 || scalar.value == 0x7F
}
/// The string as the text of a terminal key event, or nil when it is empty
/// or begins with an ASCII control character.
///
/// - Note: Control characters are encoded by Ghostty itself so that the
/// physical key and its modifiers remain available to protocols
/// such as the Kitty keyboard protocol.
var keyEventText: String? {
guard !isEmpty, !startsWithASCIIControlCharacter else { return nil }
return self
}
func truncate(length: Int, trailing: String = "") -> String {
let maxLength = length - trailing.count
guard maxLength > 0, !self.isEmpty, self.count > length else {

View File

@@ -110,34 +110,19 @@ struct ScriptKeyEventCommandTests {
@Test func pressCarriesLayoutText() throws {
let event = try parse("a")
// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
// #expect(event.text == String(expected))
// #expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value)
// #expect(event.consumedMods == [])
Issue.record(
"""
press should carry the layout's text and unshifted codepoint; \
got text \(String(describing: event.text)), \
codepoint \(event.unshiftedCodepoint)
""",
severity: .warning
)
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value)
#expect(event.consumedMods == [])
}
@Test func shiftShiftsTextAndIsConsumed() throws {
let event = try parse("a", modifiers: "shift")
// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift))
// let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
// #expect(event.text == String(expected))
// #expect(event.consumedMods == .shift)
// #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
Issue.record(
"""
shift should apply to the translated text and be consumed; \
got text \(String(describing: event.text))
""",
severity: .warning
)
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift))
let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.consumedMods == .shift)
#expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
}
/// The original bug scenario: `send key "c" with modifiers "control"`
@@ -145,34 +130,20 @@ struct ScriptKeyEventCommandTests {
/// unconsumed, so core can encode the control sequence itself.
@Test func controlKeepsBaseTextAndIsNotConsumed() throws {
let event = try parse("c", modifiers: "control")
// let expected = try #require(KeyboardLayout.character(
// for: 0x08, // W3C KeyC
// modifiers: []))
// #expect(event.text == String(expected))
// #expect(event.mods == .ctrl)
// #expect(event.consumedMods == [])
Issue.record(
"""
ctrl should be stripped from translation ("c", not 0x03) and \
stay unconsumed; got text \(String(describing: event.text))
""",
severity: .warning
)
let expected = try #require(KeyboardLayout.character(
for: 0x08, // W3C KeyC
modifiers: []))
#expect(event.text == String(expected))
#expect(event.mods == .ctrl)
#expect(event.consumedMods == [])
}
@Test func optionIncludedInTranslationIsConsumed() throws {
// macos-option-as-alt=false: option participates in translation.
let event = try parse("a", modifiers: "option")
// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option))
// #expect(event.text == String(expected))
// #expect(event.consumedMods == .alt)
Issue.record(
"""
option in the translation mods should apply to the text and be \
consumed; got text \(String(describing: event.text))
""",
severity: .warning
)
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option))
#expect(event.text == String(expected))
#expect(event.consumedMods == .alt)
}
@Test func optionExcludedFromTranslationIsNotConsumed() throws {
@@ -180,16 +151,9 @@ struct ScriptKeyEventCommandTests {
// option, so it stays unconsumed and core can encode it (e.g. ESC
// prefix).
let event = try parse("a", modifiers: "option") { $0.subtracting(.alt) }
// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
// #expect(event.text == String(expected))
// #expect(event.consumedMods == [])
Issue.record(
"""
option excluded from the translation mods should leave the base \
text and stay unconsumed; got text \(String(describing: event.text))
""",
severity: .warning
)
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.consumedMods == [])
}
@Test func releaseCarriesNoText() throws {
@@ -198,16 +162,9 @@ struct ScriptKeyEventCommandTests {
modifiers: "shift",
action: "GIrl".fourCharCode
)
// let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
// #expect(event.text == nil)
// #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
Issue.record(
"""
release should carry no text but still report the unshifted \
codepoint; got codepoint \(event.unshiftedCodepoint)
""",
severity: .warning
)
let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == nil)
#expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
}
/// Keys whose layout translation is a control character (or a PUA

View File

@@ -0,0 +1,115 @@
import AppKit
import SwiftUI
import Testing
@testable import Ghostty
import GhosttyKit
/// Tests for `Ghostty.Input.KeyEvent.init(synthesizing:...)`, the derivation
/// used for programmatic key input such as AppleScript's `send key`.
///
/// Expected characters are computed through `KeyboardLayout` rather than
/// hardcoded so the tests hold on any keyboard layout; what's under test is
/// the text/consumed/unshifted derivation, not the layout itself.
@MainActor
struct KeyEventSynthesizeTests {
private let keyCodeA: UInt16 = 0x00 // W3C KeyA
@Test func pressHasLayoutText() throws {
let event = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: [], translationMods: [])
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value)
#expect(event.consumedMods == [])
}
@Test func shiftAppliesToTextAndIsConsumed() throws {
let event = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: .shift, translationMods: .shift)
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift))
let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.consumedMods == .shift)
#expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
}
@Test func controlIsNeverConsumedAndDoesNotAffectText() throws {
// Core passes ctrl through translation mods; the event must still
// produce the base character ("a", not 0x01) and not consume ctrl.
let event = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: .ctrl, translationMods: .ctrl)
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.consumedMods == [])
}
@Test func optionFollowsTranslationMods() throws {
// macos-option-as-alt=false: option participates in translation and
// is consumed.
let translated = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: .alt, translationMods: .alt)
let optionChar = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option))
#expect(translated.text == String(optionChar))
#expect(translated.consumedMods == .alt)
// macos-option-as-alt=true: option is excluded from translation and
// remains unconsumed, so core can encode it (e.g. ESC prefix).
let asAlt = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: .alt, translationMods: [])
let baseChar = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(asAlt.text == String(baseChar))
#expect(asAlt.consumedMods == [])
}
@Test func releaseHasNoText() throws {
let event = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .release, mods: .shift, translationMods: .shift)
let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == nil)
#expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
#expect(event.consumedMods == .shift)
}
/// Every functional key with a Mac keycode. Their layout translations are
/// control characters (or nothing), which must never be attached as text
/// or reported as an unshifted codepoint; core encodes them from the key
/// enum. This also guards against a translation leaking through in some
/// other form, such as a PUA function-key character.
@Test(arguments: [
Ghostty.Input.Key.enter, .numpadEnter, .escape, .tab, .backspace,
.delete, .insert, .home, .end, .pageUp, .pageDown,
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
.contextMenu, .numLock,
.f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10,
.f11, .f12, .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20,
])
func controlKeyTranslationsProduceNoTextOrCodepoint(key: Ghostty.Input.Key) {
let event = Ghostty.Input.KeyEvent(
synthesizing: key, action: .press, mods: [], translationMods: [])
#expect(event.text == nil)
#expect(event.unshiftedCodepoint == 0)
}
}
/// The menu-shortcut path must translate the key equivalent with only the
/// command modifier applied: command can select a distinct layout table, while
/// the other modifiers live in the shortcut's modifier mask.
@MainActor
struct KeyboardShortcutTranslationTests {
@Test func keyEquivalentIgnoresNonCommandModifiers() throws {
var trigger = ghostty_input_trigger_s()
trigger.tag = GHOSTTY_TRIGGER_PHYSICAL
trigger.key.physical = GHOSTTY_KEY_BACKQUOTE
trigger.mods = ghostty_input_mods_e(
GHOSTTY_MODS_SUPER.rawValue | GHOSTTY_MODS_SHIFT.rawValue | GHOSTTY_MODS_ALT.rawValue)
let shortcut = try #require(Ghostty.keyboardShortcut(for: trigger))
let expected = try #require(KeyboardLayout.character(
for: 0x32, // W3C Backquote
modifiers: .command))
#expect(shortcut.key.character == expected)
#expect(shortcut.modifiers.contains(.shift))
#expect(shortcut.modifiers.contains(.option))
#expect(shortcut.modifiers.contains(.command))
}
}