Support modifier+key combinations in send-key (ctrl+enter, shift+tab, etc.) (#1994)

* Support modifier+key combinations in send-key (ctrl+enter, shift+tab, etc.)

The send-key command only supported a few hardcoded ctrl+letter combos
and bare special keys. Generic modifier combinations like ctrl+enter
(needed for GitHub Copilot CLI submission) returned "Unknown key".

Now the parser splits on + and - separators, accumulates modifier flags
(ctrl, shift, alt/opt, cmd/super), and resolves the base key via a new
keycodeForNamedKey helper that maps named keys (enter, tab, escape,
backspace, space, arrow keys) to virtual keycodes.

Closes #1990

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix delete key mapping and filter empty parts in modifier parser

- Separate "delete" (forward delete, kVK_ForwardDelete) from "backspace"
  (kVK_Delete) in keycodeForNamedKey. The original mapping had both
  pointing to kVK_Delete (which is actually Backspace on macOS).
- Filter empty strings from split results to reject malformed inputs
  like "+shift+a" that would produce an empty modifier part.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix single named keys silently failing and remove force unwrap in send-key

Single named keys like "space", "up", "delete" were rejected by
guard parts.count >= 2 before reaching keycodeForNamedKey(). Now
standalone named keys are handled before the modifier-combo path.
Also replaced parts.last! with guard let for safe unwrapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Achieve 2026-03-27 09:45:09 +08:00 committed by GitHub
parent ccd84bd578
commit f3c797ee44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -13284,6 +13284,23 @@ class TerminalController {
}
}
private func keycodeForNamedKey(_ name: String) -> UInt32? {
switch name {
case "enter", "return": return UInt32(kVK_Return)
case "tab": return UInt32(kVK_Tab)
case "escape", "esc": return UInt32(kVK_Escape)
case "backspace": return UInt32(kVK_Delete)
case "delete": return UInt32(kVK_ForwardDelete)
case "space": return UInt32(kVK_Space)
case "up": return UInt32(kVK_UpArrow)
case "down": return UInt32(kVK_DownArrow)
case "left": return UInt32(kVK_LeftArrow)
case "right": return UInt32(kVK_RightArrow)
case "\\": return UInt32(kVK_ANSI_Backslash)
default: return nil
}
}
private func sendNamedKey(_ surface: ghostty_surface_t, keyName: String) -> Bool {
switch keyName.lowercased() {
case "ctrl-c", "ctrl+c", "sigint":
@ -13341,12 +13358,43 @@ class TerminalController {
sendKeyEvent(surface: surface, keycode: UInt32(kVK_PageDown))
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)
// Parse modifier+key combinations (e.g. "ctrl+enter", "shift+tab",
// "ctrl+shift+a") or standalone named keys (e.g. "space", "up").
// Separators: '+' or '-'.
let parts = keyName.lowercased().split(separator: "+").flatMap { $0.split(separator: "-") }.map(String.init).filter { !$0.isEmpty }
guard let baseKey = parts.last else { return false }
// Single named key without modifiers (e.g. "space", "up", "delete")
if parts.count == 1 {
if let keycode = keycodeForNamedKey(baseKey) {
sendKeyEvent(surface: surface, keycode: keycode)
return true
}
if baseKey.count == 1, let char = baseKey.first, let keycode = keycodeForLetter(char) {
sendKeyEvent(surface: surface, keycode: keycode)
return true
}
return false
}
var mods = GHOSTTY_MODS_NONE
for mod in parts.dropLast() {
switch mod {
case "ctrl", "control": mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_CTRL.rawValue)
case "shift": mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_SHIFT.rawValue)
case "alt", "opt", "option": mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_ALT.rawValue)
case "cmd", "command", "super": mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_SUPER.rawValue)
default: return false
}
}
if let keycode = keycodeForNamedKey(baseKey) {
sendKeyEvent(surface: surface, keycode: keycode, mods: mods)
return true
}
if baseKey.count == 1, let char = baseKey.first, let keycode = keycodeForLetter(char) {
sendKeyEvent(surface: surface, keycode: keycode, mods: mods)
return true
}
return false
}