From f3c797ee4488b7a5f4e2a67679ad03ca6aed592c Mon Sep 17 00:00:00 2001 From: Achieve Date: Fri, 27 Mar 2026 09:45:09 +0800 Subject: [PATCH] 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- Sources/TerminalController.swift | 56 +++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 358f96d9..f1d6c377 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 }