Fix socket key input and add OSC 777 test

This commit is contained in:
Lawrence Chen 2026-01-29 01:19:19 -08:00
parent 4460b54fa5
commit ba68dc3637
3 changed files with 165 additions and 144 deletions

View file

@ -510,7 +510,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = YES;
@ -526,7 +526,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -555,7 +555,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = YES;
@ -571,7 +571,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -624,10 +624,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -641,10 +641,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -1,4 +1,5 @@
import AppKit
import Carbon.HIToolbox
import Foundation
/// Unix socket-based controller for programmatic terminal control
@ -694,6 +695,123 @@ class TerminalController {
return result.isEmpty ? "ERROR: No tab selected" : result
}
private func sendKeyEvent(
surface: ghostty_surface_t,
keycode: UInt32,
mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE,
text: String? = nil
) {
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = keycode
keyEvent.mods = mods
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.unshifted_codepoint = 0
keyEvent.composing = false
if let text {
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
} else {
keyEvent.text = nil
_ = ghostty_surface_key(surface, keyEvent)
}
}
private func sendTextEvent(surface: ghostty_surface_t, text: String) {
sendKeyEvent(surface: surface, keycode: 0, text: text)
}
private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool {
switch scalar.value {
case 0x0A, 0x0D:
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return))
return true
case 0x09:
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab))
return true
case 0x1B:
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape))
return true
case 0x7F:
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete))
return true
default:
return false
}
}
private func keycodeForLetter(_ letter: Character) -> UInt32? {
switch String(letter).lowercased() {
case "a": return UInt32(kVK_ANSI_A)
case "b": return UInt32(kVK_ANSI_B)
case "c": return UInt32(kVK_ANSI_C)
case "d": return UInt32(kVK_ANSI_D)
case "e": return UInt32(kVK_ANSI_E)
case "f": return UInt32(kVK_ANSI_F)
case "g": return UInt32(kVK_ANSI_G)
case "h": return UInt32(kVK_ANSI_H)
case "i": return UInt32(kVK_ANSI_I)
case "j": return UInt32(kVK_ANSI_J)
case "k": return UInt32(kVK_ANSI_K)
case "l": return UInt32(kVK_ANSI_L)
case "m": return UInt32(kVK_ANSI_M)
case "n": return UInt32(kVK_ANSI_N)
case "o": return UInt32(kVK_ANSI_O)
case "p": return UInt32(kVK_ANSI_P)
case "q": return UInt32(kVK_ANSI_Q)
case "r": return UInt32(kVK_ANSI_R)
case "s": return UInt32(kVK_ANSI_S)
case "t": return UInt32(kVK_ANSI_T)
case "u": return UInt32(kVK_ANSI_U)
case "v": return UInt32(kVK_ANSI_V)
case "w": return UInt32(kVK_ANSI_W)
case "x": return UInt32(kVK_ANSI_X)
case "y": return UInt32(kVK_ANSI_Y)
case "z": return UInt32(kVK_ANSI_Z)
default: return nil
}
}
private func sendNamedKey(_ surface: ghostty_surface_t, keyName: String) -> Bool {
switch keyName.lowercased() {
case "ctrl-c", "ctrl+c", "sigint":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_C), mods: GHOSTTY_MODS_CTRL)
return true
case "ctrl-d", "ctrl+d", "eof":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_D), mods: GHOSTTY_MODS_CTRL)
return true
case "ctrl-z", "ctrl+z", "sigtstp":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Z), mods: GHOSTTY_MODS_CTRL)
return true
case "ctrl-\\", "ctrl+\\", "sigquit":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Backslash), mods: GHOSTTY_MODS_CTRL)
return true
case "enter", "return":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return))
return true
case "tab":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab))
return true
case "escape", "esc":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape))
return true
case "backspace":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete))
return true
default:
if keyName.lowercased().hasPrefix("ctrl-") || keyName.lowercased().hasPrefix("ctrl+") {
let letter = keyName.dropFirst(5)
if letter.count == 1, let char = letter.first, let keycode = keycodeForLetter(char) {
sendKeyEvent(surface: surface, keycode: keycode, mods: GHOSTTY_MODS_CTRL)
return true
}
}
return false
}
}
private func sendInput(_ text: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
@ -712,18 +830,13 @@ class TerminalController {
.replacingOccurrences(of: "\\r", with: "\r")
.replacingOccurrences(of: "\\t", with: "\t")
// Send each character as a key event (like typing)
for char in unescaped {
String(char).withCString { ptr in
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = 0
keyEvent.mods = GHOSTTY_MODS_NONE
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = ptr
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
if char.unicodeScalars.count == 1,
let scalar = char.unicodeScalars.first,
handleControlScalar(scalar, surface: surface) {
continue
}
sendTextEvent(surface: surface, text: String(char))
}
success = true
}
@ -748,16 +861,12 @@ class TerminalController {
.replacingOccurrences(of: "\\t", with: "\t")
for char in unescaped {
String(char).withCString { ptr in
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = 0
keyEvent.mods = GHOSTTY_MODS_NONE
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = ptr
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
if char.unicodeScalars.count == 1,
let scalar = char.unicodeScalars.first,
handleControlScalar(scalar, surface: surface) {
continue
}
sendTextEvent(surface: surface, text: String(char))
}
success = true
}
@ -776,72 +885,7 @@ class TerminalController {
return
}
// Helper to send a key event with text
func sendKeyEvent(text: String, mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE) {
text.withCString { ptr in
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = 0
keyEvent.mods = mods
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = ptr
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
}
}
switch keyName.lowercased() {
case "ctrl-c", "ctrl+c", "sigint":
// Send Ctrl+C - the control character 0x03 (ETX)
// Note: We send the raw control character, which the terminal
// interprets as an interrupt signal
sendKeyEvent(text: "\u{03}")
success = true
case "ctrl-d", "ctrl+d", "eof":
// Send Ctrl+D - the control character 0x04 (EOT)
sendKeyEvent(text: "\u{04}")
success = true
case "ctrl-z", "ctrl+z", "sigtstp":
// Send Ctrl+Z - the control character 0x1A (SUB)
sendKeyEvent(text: "\u{1A}")
success = true
case "ctrl-\\", "ctrl+\\", "sigquit":
// Send Ctrl+\ - the control character 0x1C (FS)
sendKeyEvent(text: "\u{1C}")
success = true
case "enter", "return":
sendKeyEvent(text: "\r")
success = true
case "tab":
sendKeyEvent(text: "\t")
success = true
case "escape", "esc":
sendKeyEvent(text: "\u{1B}")
success = true
case "backspace":
sendKeyEvent(text: "\u{7F}")
success = true
default:
// Check for ctrl-<letter> pattern
if keyName.lowercased().hasPrefix("ctrl-") || keyName.lowercased().hasPrefix("ctrl+") {
let letter = keyName.dropFirst(5).lowercased()
if letter.count == 1, let char = letter.first, char.isLetter {
// Convert letter to control character (a=1, b=2, ..., z=26)
let ctrlCode = UInt8(char.asciiValue! - Character("a").asciiValue! + 1)
let ctrlChar = String(UnicodeScalar(ctrlCode))
sendKeyEvent(text: ctrlChar)
success = true
}
}
}
success = sendNamedKey(surface, keyName: keyName)
}
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
}
@ -857,59 +901,10 @@ class TerminalController {
var success = false
DispatchQueue.main.sync {
guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return }
func sendKeyEvent(text: String, mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE) {
text.withCString { ptr in
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = 0
keyEvent.mods = mods
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = ptr
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
}
}
switch keyName.lowercased() {
case "ctrl-c", "ctrl+c", "sigint":
sendKeyEvent(text: "\u{03}")
success = true
case "ctrl-d", "ctrl+d", "eof":
sendKeyEvent(text: "\u{04}")
success = true
case "ctrl-z", "ctrl+z", "sigtstp":
sendKeyEvent(text: "\u{1A}")
success = true
case "ctrl-\\", "ctrl+\\", "sigquit":
sendKeyEvent(text: "\u{1C}")
success = true
case "enter", "return":
sendKeyEvent(text: "\r")
success = true
case "tab":
sendKeyEvent(text: "\t")
success = true
case "escape", "esc":
sendKeyEvent(text: "\u{1B}")
success = true
case "backspace":
sendKeyEvent(text: "\u{7F}")
success = true
default:
if keyName.lowercased().hasPrefix("ctrl-") || keyName.lowercased().hasPrefix("ctrl+") {
let letter = keyName.dropFirst(5).lowercased()
if letter.count == 1, let char = letter.first, char.isLetter {
let ctrlCode = UInt8(char.asciiValue! - Character("a").asciiValue! + 1)
let ctrlChar = String(UnicodeScalar(ctrlCode))
sendKeyEvent(text: ctrlChar)
success = true
}
}
}
success = sendNamedKey(surface, keyName: keyName)
}
return success ? "OK" : "ERROR: Failed to send key"
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
}
deinit {

View file

@ -173,6 +173,31 @@ def test_kitty_notification_chunked(client: cmux) -> TestResult:
return result
def test_rxvt_notification_osc777(client: cmux) -> TestResult:
result = TestResult("RXVT OSC 777 Notification")
try:
client.clear_notifications()
client.set_app_focus(False)
# Avoid Ghostty's 1s desktop notification rate limit.
time.sleep(1.1)
surface = focused_surface_index(client)
command = "printf '\\x1b]777;notify;OSC777 Title;OSC777 Body\\x07'"
client.send_surface(surface, command + "\\n")
items = wait_for_notifications(client, 1)
if len(items) != 1:
result.failure(f"Expected 1 notification, got {len(items)}")
elif items[0]["title"] != "OSC777 Title" or items[0]["body"] != "OSC777 Body":
result.failure(
f"Expected title/body 'OSC777 Title'/'OSC777 Body', got "
f"'{items[0]['title']}'/'{items[0]['body']}'"
)
else:
result.success("OSC 777 notification received")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Mark Read On Panel Focus")
try:
@ -410,6 +435,7 @@ def run_tests() -> int:
results.append(test_not_suppressed_when_inactive(client))
results.append(test_kitty_notification_simple(client))
results.append(test_kitty_notification_chunked(client))
results.append(test_rxvt_notification_osc777(client))
results.append(test_mark_read_on_focus_change(client))
results.append(test_mark_read_on_app_active(client))
results.append(test_mark_read_on_tab_switch(client))