初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)

- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连
- M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire
- M1.5: tmux -CC 原生 tab(MVP)
- 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/
- 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口)
- 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名
- 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-24 10:20:46 +08:00
commit aa92d0e676
2761 changed files with 803505 additions and 0 deletions

View File

@@ -0,0 +1,346 @@
//
// TerminalKeyEventHandler@AppKit.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
// Reference:
// - ghostty-org/ghostty
// - macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
// Translation modifiers, interpretKeyEvents dispatch, and text emission are
// kept close to Ghostty's native AppKit implementation to reduce long-term
// drift from upstream keyboard/IME semantics.
//
#if canImport(AppKit) && !canImport(UIKit)
import AppKit
import GhosttyKit
@MainActor
final class TerminalKeyEventHandler {
private weak var view: AppTerminalView?
var inputMethodHandler: TerminalTextInputHandler?
private var interpretedCommandSelector: Selector?
init(view: AppTerminalView) {
self.view = view
inputMethodHandler = TerminalTextInputHandler(view: view)
}
nonisolated static func shouldUseDirectInput(
modifierFlags: NSEvent.ModifierFlags
) -> Bool {
modifierFlags.intersection([.shift, .control, .option, .command]).isEmpty
}
nonisolated static func shouldReplayInterpretedCommand(
_ selector: Selector
) -> Bool {
// AppKit sometimes resolves non-text keys into editing commands
// (for example Shift-Tab -> insertBacktab:). In a terminal, those
// commands still need to reach Ghostty as the original hardware key.
let _ = selector
return true
}
func handleKeyDown(with event: NSEvent) {
guard let view, let surface = view.surface else { return }
if handleDirectInputIfNeeded(event) {
return
}
let action: ghostty_input_action_e = event.isARepeat
? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
let translationEvent = translatedEvent(for: event, on: surface)
inputMethodHandler?.startCollectingText()
interpretedCommandSelector = nil
let markedTextBefore = inputMethodHandler?.hasMarkedText == true
let keyboardIdBefore = markedTextBefore ? nil : KeyboardLayout.id
view.lastPerformKeyEvent = nil
view.interpretKeyEvents([translationEvent])
if !markedTextBefore, keyboardIdBefore != KeyboardLayout.id {
_ = inputMethodHandler?.finishCollectingText()
return
}
inputMethodHandler?.syncPreedit(clearIfNeeded: markedTextBefore)
if let collected = inputMethodHandler?.finishCollectingText() {
var input = event.buildKeyInput(
action: action,
translationModifiers: translationEvent.modifierFlags
)
for text in collected {
text.withCString { ptr in
input.text = ptr
surface.sendKeyEvent(input)
}
}
return
}
if let selector = interpretedCommandSelector {
interpretedCommandSelector = nil
if Self.shouldReplayInterpretedCommand(selector) {
sendKeyEvent(
for: event,
translationEvent: translationEvent,
action: action,
to: surface,
includeText: false,
composing: inputMethodHandler?.hasMarkedText == true || markedTextBefore
)
return
}
}
sendKeyEvent(
for: event,
translationEvent: translationEvent,
action: action,
to: surface,
includeText: true,
composing: inputMethodHandler?.hasMarkedText == true || markedTextBefore
)
}
func handleKeyUp(with event: NSEvent) {
guard let view, let surface = view.surface else { return }
if shouldBypassGhosttyForDirectInput(event) {
return
}
var input = event.buildKeyInput(action: GHOSTTY_ACTION_RELEASE)
input.text = nil
surface.sendKeyEvent(input)
}
func handleFlagsChanged(with event: NSEvent) {
guard let surface = view?.surface else { return }
guard inputMethodHandler?.hasMarkedText != true else { return }
let mod: UInt32
switch event.keyCode {
case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue
case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue
case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue
case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue
case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue
default: return
}
let mods = TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
var action = GHOSTTY_ACTION_RELEASE
if mods.rawValue & mod != 0 {
let sidePressed: Bool = switch event.keyCode {
case 0x3C:
event.modifierFlags.rawValue
& UInt(NX_DEVICERSHIFTKEYMASK) != 0
case 0x3E:
event.modifierFlags.rawValue
& UInt(NX_DEVICERCTLKEYMASK) != 0
case 0x3D:
event.modifierFlags.rawValue
& UInt(NX_DEVICERALTKEYMASK) != 0
case 0x36:
event.modifierFlags.rawValue
& UInt(NX_DEVICERCMDKEYMASK) != 0
default:
true
}
if sidePressed {
action = GHOSTTY_ACTION_PRESS
}
}
var input = event.buildKeyInput(action: action)
input.text = nil
surface.sendKeyEvent(input)
}
private func sendKeyEvent(
for event: NSEvent,
translationEvent: NSEvent,
action: ghostty_input_action_e,
to surface: TerminalSurface,
includeText: Bool,
composing: Bool = false
) {
var input = event.buildKeyInput(
action: action,
translationModifiers: translationEvent.modifierFlags
)
input.composing = composing
guard includeText,
let chars = translationEvent.filteredCharacters,
!chars.isEmpty
else {
surface.sendKeyEvent(input)
return
}
chars.withCString { ptr in
input.text = ptr
surface.sendKeyEvent(input)
}
}
private func handleDirectInputIfNeeded(_ event: NSEvent) -> Bool {
guard let view else { return false }
// During IME composition, AppKit needs to keep ownership of editing
// commands so marked text can shrink, cancel, and move correctly.
guard inputMethodHandler?.hasMarkedText != true else { return false }
guard Self.shouldUseDirectInput(modifierFlags: event.modifierFlags) else {
return false
}
let delivery = TerminalHardwareKeyRouter.routeAppKit(
keyCode: event.keyCode,
backend: view.configuration.backend
)
guard case let .data(sequence) = delivery else { return false }
guard case let .inMemory(session) = view.configuration.backend else { return false }
session.sendInput(sequence)
return true
}
private func shouldBypassGhosttyForDirectInput(_ event: NSEvent) -> Bool {
guard let view else { return false }
guard Self.shouldUseDirectInput(modifierFlags: event.modifierFlags) else {
return false
}
return TerminalHardwareKeyRouter.routeAppKit(
keyCode: event.keyCode,
backend: view.configuration.backend
).isDirectInput
}
private func translatedEvent(
for event: NSEvent,
on surface: TerminalSurface
) -> NSEvent {
guard let rawSurface = surface.rawValue else {
return event
}
let translatedMods = eventModifierFlags(
from: ghostty_surface_key_translation_mods(
rawSurface,
TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
)
)
var finalModifiers = event.modifierFlags
for flag in [
NSEvent.ModifierFlags.shift,
.control,
.option,
.command,
] {
if translatedMods.contains(flag) {
finalModifiers.insert(flag)
} else {
finalModifiers.remove(flag)
}
}
guard finalModifiers != event.modifierFlags else {
return event
}
return NSEvent.keyEvent(
with: event.type,
location: event.locationInWindow,
modifierFlags: finalModifiers,
timestamp: event.timestamp,
windowNumber: event.windowNumber,
context: nil,
characters: event.characters(byApplyingModifiers: finalModifiers) ?? "",
charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "",
isARepeat: event.isARepeat,
keyCode: event.keyCode
) ?? event
}
private func eventModifierFlags(from mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags {
var flags = NSEvent.ModifierFlags()
if mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0 { flags.insert(.shift) }
if mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { flags.insert(.control) }
if mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0 { flags.insert(.option) }
if mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0 { flags.insert(.command) }
return flags
}
func recordInterpretedCommand(_ selector: Selector) {
interpretedCommandSelector = selector
}
}
// MARK: - NSEvent Terminal Input Helpers
extension NSEvent {
func buildKeyInput(
action: ghostty_input_action_e,
translationModifiers: NSEvent.ModifierFlags? = nil
) -> ghostty_input_key_s {
var input = ghostty_input_key_s()
input.action = action
input.keycode = UInt32(keyCode)
input.composing = false
input.text = nil
input.mods = TerminalInputModifiers(from: modifierFlags).ghosttyMods
// Consumed modifiers: modifiers the key binding system should
// treat as already handled by text generation. We pass through
// all modifiers except control and command, which should remain
// available for keybind matching.
var consumedFlags = translationModifiers ?? modifierFlags
consumedFlags.remove(.control)
consumedFlags.remove(.command)
input.consumed_mods = TerminalInputModifiers(from: consumedFlags).ghosttyMods
if type == .keyDown || type == .keyUp,
let chars = characters(byApplyingModifiers: []),
let codepoint = chars.unicodeScalars.first
{
input.unshifted_codepoint = codepoint.value
}
return input
}
var filteredCharacters: String? {
guard let filtered = TerminalInputText.filteredFunctionKeyText(characters) else {
return nil
}
guard filtered.count == 1,
let scalar = filtered.unicodeScalars.first
else {
return filtered
}
// macOS encodes function keys as Private Use Area scalars
// these have no printable representation.
// When the control modifier produces a raw control character,
// re-derive printable text without the control modifier so
// Ghostty can map the physical key correctly.
if scalar.isASCIIControl {
var flags = modifierFlags
flags.remove(.control)
return TerminalInputText.filteredFunctionKeyText(
characters(byApplyingModifiers: flags)
)
}
return filtered
}
}
extension UnicodeScalar {
var isASCIIControl: Bool {
value < 0x20
}
}
#endif