初始提交: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,295 @@
//
// AppTerminalView+Input.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
#if canImport(AppKit) && !canImport(UIKit)
import AppKit
import GhosttyKit
extension AppTerminalView {
override open func keyDown(with event: NSEvent) {
inputHandler?.handleKeyDown(with: event)
}
override open func performKeyEquivalent(with event: NSEvent) -> Bool {
guard event.type == .keyDown else { return false }
guard window?.firstResponder === self else { return false }
guard let surface else { return false }
if keyIsBinding(event, on: surface) {
keyDown(with: event)
return true
}
let equivalent: String
switch event.charactersIgnoringModifiers {
case "\r":
guard event.modifierFlags.contains(.control) else {
return false
}
equivalent = "\r"
case "/":
guard event.modifierFlags.contains(.control),
event.modifierFlags.isDisjoint(with: [.shift, .command, .option])
else {
return false
}
equivalent = "_"
default:
if event.timestamp == 0 {
return false
}
if !event.modifierFlags.contains(.command),
!event.modifierFlags.contains(.control)
{
lastPerformKeyEvent = nil
return false
}
if let lastPerformKeyEvent,
lastPerformKeyEvent == event.timestamp
{
self.lastPerformKeyEvent = nil
equivalent = event.characters ?? ""
break
}
lastPerformKeyEvent = event.timestamp
return false
}
guard let translatedEvent = NSEvent.keyEvent(
with: .keyDown,
location: event.locationInWindow,
modifierFlags: event.modifierFlags,
timestamp: event.timestamp,
windowNumber: event.windowNumber,
context: nil,
characters: equivalent,
charactersIgnoringModifiers: equivalent,
isARepeat: event.isARepeat,
keyCode: event.keyCode
) else {
return false
}
keyDown(with: translatedEvent)
return true
}
override open func keyUp(with event: NSEvent) {
inputHandler?.handleKeyUp(with: event)
}
override open func flagsChanged(with event: NSEvent) {
inputHandler?.handleFlagsChanged(with: event)
}
override open func doCommand(by selector: Selector) {
if let lastPerformKeyEvent,
let current = NSApp.currentEvent,
lastPerformKeyEvent == current.timestamp
{
NSApp.sendEvent(current)
return
}
if TerminalKeyEventHandler.shouldReplayInterpretedCommand(selector) {
inputHandler?.recordInterpretedCommand(selector)
}
}
@IBAction open func copy(_: Any?) {
_ = copySelectedTextToPasteboard()
}
@IBAction func paste(_: Any?) {
if let text = NSPasteboard.general.string(forType: .string) {
TerminalDebugLog.log(
.input,
"paste binding bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text))"
)
}
_ = surface?.performBindingAction("paste_from_clipboard")
}
@IBAction override open func selectAll(_: Any?) {
_ = surface?.performBindingAction("select_all")
}
internal func mousePoint(from event: NSEvent) -> (x: CGFloat, y: CGFloat) {
let point = convert(event.locationInWindow, from: nil)
return (point.x, bounds.height - point.y)
}
override open func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
let (x, y) = mousePoint(from: event)
let mods = TerminalInputModifiers(from: event.modifierFlags)
pointerSelectionStartPoint = CGPoint(x: x, y: y)
pendingSelectionMenuPoint = nil
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_PRESS,
button: GHOSTTY_MOUSE_LEFT,
mods: mods.ghosttyMods
)
}
override open func mouseUp(with event: NSEvent) {
let (x, y) = mousePoint(from: event)
let mods = TerminalInputModifiers(from: event.modifierFlags)
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_RELEASE,
button: GHOSTTY_MOUSE_LEFT,
mods: mods.ghosttyMods
)
finishPointerSelection(at: CGPoint(x: x, y: y))
}
override open func rightMouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
let (x, y) = mousePoint(from: event)
let mods = TerminalInputModifiers(from: event.modifierFlags)
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
if let menuPoint = selectionMenuPoint(at: CGPoint(x: x, y: y)) {
pendingSelectionMenuPoint = menuPoint
return
}
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_PRESS,
button: GHOSTTY_MOUSE_RIGHT,
mods: mods.ghosttyMods
)
}
override open func rightMouseUp(with event: NSEvent) {
let (x, y) = mousePoint(from: event)
let mods = TerminalInputModifiers(from: event.modifierFlags)
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
if pendingSelectionMenuPoint != nil {
pendingSelectionMenuPoint = nil
showSelectionCopyMenu(with: event)
return
}
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_RELEASE,
button: GHOSTTY_MOUSE_RIGHT,
mods: mods.ghosttyMods
)
}
override open func menu(for event: NSEvent) -> NSMenu? {
let (x, y) = mousePoint(from: event)
guard selectionMenuPoint(at: CGPoint(x: x, y: y)) != nil else {
return super.menu(for: event)
}
return selectionContextMenu()
}
override open func otherMouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
let (x, y) = mousePoint(from: event)
let mods = TerminalInputModifiers(from: event.modifierFlags)
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_PRESS,
button: GHOSTTY_MOUSE_MIDDLE,
mods: mods.ghosttyMods
)
}
override open func otherMouseUp(with event: NSEvent) {
let (x, y) = mousePoint(from: event)
let mods = TerminalInputModifiers(from: event.modifierFlags)
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_RELEASE,
button: GHOSTTY_MOUSE_MIDDLE,
mods: mods.ghosttyMods
)
}
override open func mouseMoved(with event: NSEvent) {
let (x, y) = mousePoint(from: event)
let mods = TerminalInputModifiers(from: event.modifierFlags)
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
}
override open func mouseDragged(with event: NSEvent) {
let (x, y) = mousePoint(from: event)
updatePointerSelectionRect(to: CGPoint(x: x, y: y))
mouseMoved(with: event)
}
override open func rightMouseDragged(with event: NSEvent) {
mouseMoved(with: event)
}
override open func otherMouseDragged(with event: NSEvent) {
mouseMoved(with: event)
}
override open func scrollWheel(with event: NSEvent) {
let scrollMods = TerminalScrollModifiers(
precision: event.hasPreciseScrollingDeltas,
momentum: TerminalScrollModifiers.momentumFrom(phase: event.momentumPhase)
)
surface?.sendMouseScroll(
x: event.scrollingDeltaX,
y: event.scrollingDeltaY,
mods: scrollMods.rawValue
)
}
private func updatePointerSelectionRect(to point: CGPoint) {
guard let start = pointerSelectionStartPoint else { return }
lastPointerSelectionRect = CGRect(
x: min(start.x, point.x),
y: min(start.y, point.y),
width: abs(start.x - point.x),
height: abs(start.y - point.y)
).insetBy(dx: -2, dy: -2)
}
private func finishPointerSelection(at point: CGPoint) {
defer { pointerSelectionStartPoint = nil }
guard let start = pointerSelectionStartPoint else { return }
let dragDistance = hypot(point.x - start.x, point.y - start.y)
if dragDistance < 2 {
lastPointerSelectionRect = nil
} else {
updatePointerSelectionRect(to: point)
}
}
private func showSelectionCopyMenu(with event: NSEvent) {
let menu = selectionContextMenu()
NSMenu.popUpContextMenu(menu, with: event, for: self)
}
private func keyIsBinding(
_ event: NSEvent,
on surface: TerminalSurface
) -> Bool {
guard let rawSurface = surface.rawValue else {
return false
}
var keyEvent = event.buildKeyInput(action: GHOSTTY_ACTION_PRESS)
var bindingFlags = ghostty_binding_flags_e(rawValue: 0)
let text = event.characters ?? ""
return text.withCString { ptr in
keyEvent.text = ptr
return ghostty_surface_key_is_binding(rawSurface, keyEvent, &bindingFlags)
}
}
}
#endif

View File

@@ -0,0 +1,230 @@
//
// AppTerminalView+Lifecycle.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
#if canImport(AppKit) && !canImport(UIKit)
import AppKit
extension AppTerminalView {
func setupTrackingArea() {
let options: NSTrackingArea.Options = [
.mouseEnteredAndExited,
.mouseMoved,
.inVisibleRect,
.activeAlways,
]
let area = NSTrackingArea(
rect: bounds,
options: options,
owner: self,
userInfo: nil
)
addTrackingArea(area)
}
override open func updateTrackingAreas() {
super.updateTrackingAreas()
trackingAreas.forEach { removeTrackingArea($0) }
setupTrackingArea()
}
override open var acceptsFirstResponder: Bool {
true
}
override open func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
core.setFocus(true)
onFocusChange?(true)
return result
}
override open func resignFirstResponder() -> Bool {
let result = super.resignFirstResponder()
core.setFocus(false)
onFocusChange?(false)
return result
}
override open func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
removeWindowObservers()
if window != nil {
// SwiftUI/AppKit can temporarily detach and reattach the terminal view while
// diffing the view hierarchy. Rebuilding on every reattach discards Ghostty's
// scrollback/state, so only create a new surface when one does not already exist.
if surface == nil {
core.rebuildIfReady()
} else {
core.synchronizeMetrics()
}
updateMetalLayerMetrics()
updateColorScheme()
core.startDisplayLink()
core.requestImmediateTick()
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidBecomeKey),
name: NSWindow.didBecomeKeyNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidResignKey),
name: NSWindow.didResignKeyNotification,
object: window
)
// Cross-display rescue: AppKit posts didChangeScreen when the
// window's screen reference changes, even when the new screen
// has the same backingScaleFactor (in which case
// viewDidChangeBackingProperties does not fire). Listening
// here lets us re-run metric sync on every screen transition
// required for the case where two displays share scale but
// differ in geometry / color profile, and harmless when
// viewDidChangeBackingProperties also fires for the
// different-scale case.
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidChangeScreen),
name: NSWindow.didChangeScreenNotification,
object: window
)
} else {
core.stopDisplayLink()
core.setFocus(false)
}
}
@objc func windowDidBecomeKey(_: Notification) {
let focused = window?.isKeyWindow == true
&& window?.firstResponder === self
core.setFocus(focused)
onFocusChange?(focused)
}
@objc func windowDidResignKey(_: Notification) {
core.setFocus(false)
onFocusChange?(false)
}
@objc func windowDidChangeScreen(_: Notification) {
// Defer one runloop tick so AppKit's layout pass and the
// window's new backingScaleFactor have both settled before we
// re-derive metrics. Calling synchronously can race with the
// layout pass and re-introduce the drift we're trying to fix.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
updateMetalLayerMetrics()
core.synchronizeMetrics()
core.requestImmediateTick()
}
}
private func removeWindowObservers() {
// Remove any existing key-window observers before registering for the
// current window. AppKit can move the view directly between windows
// without an intermediate nil attachment.
NotificationCenter.default.removeObserver(
self,
name: NSWindow.didBecomeKeyNotification,
object: nil
)
NotificationCenter.default.removeObserver(
self,
name: NSWindow.didResignKeyNotification,
object: nil
)
NotificationCenter.default.removeObserver(
self,
name: NSWindow.didChangeScreenNotification,
object: nil
)
}
override open func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
core.fitToSize()
core.requestImmediateTick()
}
override open func layout() {
super.layout()
core.fitToSize()
core.requestImmediateTick()
}
override open func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
updateMetalLayerMetrics()
core.fitToSize()
core.requestImmediateTick()
}
public func fitToSize() {
core.fitToSize()
}
func updateMetalLayerMetrics() {
guard bounds.width > 0, bounds.height > 0 else { return }
let scale = core.scaleFactor()
// Write to the actually-attached backing layer (not just the
// cached `metalLayer` ivar). The render pipeline can swap
// `self.layer` to an IOSurfaceLayer for IOSurface-backed
// compositing; once that happens the cached CAMetalLayer
// reference is detached from the view tree and writes to its
// contentsScale are no-ops as far as what's visible. The
// observable symptom is text rendered at half size after the
// window crosses to a display with a different
// backingScaleFactor.
layer?.contentsScale = scale
if let metal = layer as? CAMetalLayer {
metal.drawableSize = CGSize(
width: bounds.width * scale,
height: bounds.height * scale
)
}
// Mirror to the cached ivar in case anything else still
// reads through it during a transitional layout pass.
metalLayer?.contentsScale = scale
metalLayer?.drawableSize = CGSize(
width: bounds.width * scale,
height: bounds.height * scale
)
}
func enforceMetalLayerScale() {
let scale = core.scaleFactor()
if let layer, layer.contentsScale != scale {
layer.contentsScale = scale
}
if let metalLayer, metalLayer.contentsScale != scale {
metalLayer.contentsScale = scale
}
}
override open func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
updateColorScheme()
}
func updateColorScheme() {
let scheme: TerminalColorScheme = switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) {
case .darkAqua: .dark
default: .light
}
surface?.setColorScheme(scheme.ghosttyValue)
if let controller,
let viewState = delegate as? TerminalViewState,
viewState.controller === controller
{
viewState.adopt(terminalColorScheme: scheme)
} else {
controller?.setColorScheme(scheme)
}
}
}
#endif

View File

@@ -0,0 +1,82 @@
//
// AppTerminalView+NSTextInputClient.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
#if canImport(AppKit) && !canImport(UIKit)
import AppKit
extension AppTerminalView: @preconcurrency NSTextInputClient {
open func insertText(_ string: Any, replacementRange _: NSRange) {
inputHandler?.inputMethodHandler?.insertText(string)
}
open func setMarkedText(
_ string: Any,
selectedRange: NSRange,
replacementRange _: NSRange
) {
inputHandler?.inputMethodHandler?.setMarkedText(
string,
selectedRange: selectedRange
)
}
open func unmarkText() {
inputHandler?.inputMethodHandler?.unmarkText()
}
open func selectedRange() -> NSRange {
inputHandler?.inputMethodHandler?.currentSelectedRange()
?? NSRange(location: NSNotFound, length: 0)
}
open func markedRange() -> NSRange {
inputHandler?.inputMethodHandler?.markedRange()
?? NSRange(location: NSNotFound, length: 0)
}
open func hasMarkedText() -> Bool {
inputHandler?.inputMethodHandler?.hasMarkedText ?? false
}
open func attributedSubstring(
forProposedRange range: NSRange,
actualRange: NSRangePointer?
) -> NSAttributedString? {
inputHandler?.inputMethodHandler?.attributedSubstring(
forProposedRange: range,
actualRange: actualRange
)
}
open func validAttributesForMarkedText() -> [NSAttributedString.Key] {
[]
}
open func firstRect(
forCharacterRange _: NSRange,
actualRange _: NSRangePointer?
) -> NSRect {
guard let surface else { return .zero }
let point = surface.imePoint()
let viewRect = NSRect(
x: point.x,
y: bounds.height - point.y - point.height,
width: point.width,
height: point.height
)
guard let window else { return viewRect }
let windowRect = convert(viewRect, to: nil)
return window.convertToScreen(windowRect)
}
open func characterIndex(for _: NSPoint) -> Int {
NSNotFound
}
}
#endif

View File

@@ -0,0 +1,43 @@
//
// AppTerminalView+PublicInput.swift
// libghostty-spm
//
// Public wrappers around `TerminalSurface` write paths so hosts can
// inject bytes into the pty without reaching for internal API.
//
#if canImport(AppKit) && !canImport(UIKit)
import AppKit
extension AppTerminalView {
/// Send raw UTF-8 text directly to the underlying pty (bypassing
/// key translation). Use this for synthetic input like `\x1b[Z`
/// (Shift+Tab / CSI Z) or multi-line paste-style injections.
/// No-op when the surface has not been created yet.
public func sendText(_ text: String) {
surface?.sendText(text)
}
/// Invoke a named Ghostty binding action (e.g. "copy_to_clipboard",
/// "clear_screen"). Returns true when the action dispatched.
@discardableResult
public func performBindingAction(_ action: String) -> Bool {
surface?.performBindingAction(action) ?? false
}
/// Jump the viewport by a number of shell prompts.
///
/// Negative offsets move toward older prompts and positive offsets move
/// toward newer prompts. Prompt navigation requires shell integration.
@discardableResult
public func jumpToPrompt(by offset: Int16) -> Bool {
surface?.jumpToPrompt(by: offset) ?? false
}
/// Reveal an absolute scrollback row, where zero is the first row.
@discardableResult
public func scrollToRow(_ row: UInt) -> Bool {
surface?.scrollToRow(row) ?? false
}
}
#endif

View File

@@ -0,0 +1,176 @@
//
// AppTerminalView.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
#if canImport(AppKit) && !canImport(UIKit)
import AppKit
import GhosttyKit
@MainActor
open class AppTerminalView: NSView {
let core = TerminalSurfaceCoordinator()
var metalLayer: CAMetalLayer?
var inputHandler: TerminalKeyEventHandler?
var lastPerformKeyEvent: TimeInterval?
var pointerSelectionStartPoint: CGPoint?
var lastPointerSelectionRect: CGRect?
var pendingSelectionMenuPoint: CGPoint?
var onFocusChange: ((Bool) -> Void)?
open weak var delegate: (any TerminalSurfaceViewDelegate)? {
get { core.delegate }
set { core.delegate = newValue }
}
open var controller: TerminalController? {
get { core.controller }
set { core.controller = newValue }
}
open var configuration: TerminalSurfaceOptions {
get { core.configuration }
set { core.configuration = newValue }
}
open func setSurfaceVisible(_ visible: Bool) {
core.setDisplayVisible(visible)
}
var surface: TerminalSurface? {
core.surface
}
override public init(frame: NSRect) {
super.init(frame: frame)
commonInit()
}
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func commonInit() {
wantsLayer = true
let metal = CAMetalLayer()
metal.device = MTLCreateSystemDefaultDevice()
metal.pixelFormat = .bgra8Unorm
metal.framebufferOnly = true
metal.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
metal.isOpaque = false
metal.backgroundColor = NSColor.clear.cgColor
layer = metal
metalLayer = metal
layer?.backgroundColor = NSColor.clear.cgColor
inputHandler = TerminalKeyEventHandler(view: self)
setupTrackingArea()
core.isAttached = { [weak self] in self?.window != nil }
core.scaleFactor = { [weak self] in
Double(
self?.window?.backingScaleFactor
?? NSScreen.main?.backingScaleFactor ?? 2.0
)
}
core.viewSize = { [weak self] in
guard let self else { return (0, 0) }
return (bounds.width, bounds.height)
}
core.platformSetup = { [weak self] config in
guard let self else { return }
config.platform_tag = GHOSTTY_PLATFORM_MACOS
config.platform = ghostty_platform_u(
macos: ghostty_platform_macos_s(
nsview: Unmanaged.passUnretained(self).toOpaque()
)
)
}
core.onMetricsUpdate = { [weak self] in
self?.updateMetalLayerMetrics()
}
core.onPostRender = { [weak self] in
self?.enforceMetalLayerScale()
}
}
open func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
guard surface?.hasSelection() == true else {
TerminalDebugLog.log(
.input,
"selection menu miss point=\(selectionPointDescription(point))"
)
return nil
}
if let rect = lastPointerSelectionRect {
guard rect.insetBy(dx: -4, dy: -4).contains(point) else {
TerminalDebugLog.log(
.input,
"selection menu miss point=\(selectionPointDescription(point)) outside pointer selection"
)
return nil
}
TerminalDebugLog.log(
.input,
"selection menu hit point=\(selectionPointDescription(point)) inside pointer selection"
)
return point
}
guard surface?.selectionContainsQuicklookWord() == true else {
TerminalDebugLog.log(
.input,
"selection menu miss point=\(selectionPointDescription(point)) outside quicklook word"
)
return nil
}
TerminalDebugLog.log(
.input,
"selection menu hit point=\(selectionPointDescription(point))"
)
return point
}
open func selectionContextMenu() -> NSMenu {
let menu = NSMenu()
let copyItem = NSMenuItem(
title: "Copy",
action: #selector(copy(_:)),
keyEquivalent: ""
)
copyItem.target = self
menu.addItem(copyItem)
return menu
}
@discardableResult
open func copySelectedTextToPasteboard() -> Bool {
guard surface?.hasSelection() == true else {
return false
}
guard surface?.performBindingAction("copy_to_clipboard") == true else {
return false
}
TerminalDebugLog.log(
.input,
"selection copied to clipboard"
)
return true
}
private func selectionPointDescription(_ point: CGPoint) -> String {
"\(String(format: "%.2f", point.x))x\(String(format: "%.2f", point.y))"
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
#endif

View File

@@ -0,0 +1,22 @@
#if canImport(AppKit) && !canImport(UIKit)
import Carbon.HIToolbox
enum KeyboardLayout {
static var id: String? {
guard let inputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
else {
return nil
}
guard let rawProperty = TISGetInputSourceProperty(
inputSource,
kTISPropertyInputSourceID
) else {
return nil
}
let property = unsafeBitCast(rawProperty, to: CFString.self)
return property as String
}
}
#endif

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

View File

@@ -0,0 +1,130 @@
//
// TerminalTextInputHandler@AppKit.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
// Reference:
// - ghostty-org/ghostty
// - macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
// IME text accumulation intentionally follows Ghostty's AppKit flow:
// marked/preedit text is passed through as-is, without extra app-specific
// filtering, so composition behavior stays consistent with upstream.
//
#if canImport(AppKit) && !canImport(UIKit)
import AppKit
import GhosttyKit
@MainActor
final class TerminalTextInputHandler: NSObject {
private weak var view: AppTerminalView?
private var markedTextState = TerminalMarkedTextState()
private var accumulatedTexts: [String]?
var hasMarkedText: Bool {
markedTextState.hasMarkedText
}
init(view: AppTerminalView) {
self.view = view
super.init()
}
func startCollectingText() {
accumulatedTexts = []
}
func finishCollectingText() -> [String]? {
defer { accumulatedTexts = nil }
guard let texts = accumulatedTexts, !texts.isEmpty else { return nil }
return texts
}
// MARK: - Text Input
func insertText(_ string: Any) {
guard NSApp.currentEvent != nil else { return }
let text: String
if let attrStr = string as? NSAttributedString {
text = attrStr.string
} else if let str = string as? String {
text = str
} else {
return
}
unmarkText()
if accumulatedTexts != nil {
accumulatedTexts?.append(text)
} else {
view?.surface?.sendText(text)
}
}
func setMarkedText(
_ string: Any,
selectedRange: NSRange
) {
let text: String
if let attrStr = string as? NSAttributedString {
text = attrStr.string
} else if let str = string as? String {
text = str
} else {
return
}
markedTextState.setMarkedText(text, selectedRange: selectedRange)
if accumulatedTexts == nil {
syncPreedit()
}
}
func unmarkText() {
guard markedTextState.hasMarkedText else { return }
markedTextState.clear()
syncPreedit()
}
func currentSelectedRange() -> NSRange {
markedTextState.currentSelectedRange
}
func markedRange() -> NSRange {
markedTextState.markedRange
}
func attributedSubstring(
forProposedRange range: NSRange,
actualRange: NSRangePointer?
) -> NSAttributedString? {
guard markedTextState.hasMarkedText else {
return nil
}
let length = markedTextState.documentLength
let location = min(max(range.location, 0), length)
let end = min(max(range.location + range.length, location), length)
let clampedRange = NSRange(location: location, length: end - location)
actualRange?.pointee = clampedRange
guard let text = markedTextState.text(in: clampedRange) else {
return nil
}
return NSAttributedString(string: text)
}
func syncPreedit(clearIfNeeded: Bool = true) {
guard let text = markedTextState.text else {
guard clearIfNeeded else { return }
view?.surface?.preedit("")
return
}
view?.surface?.preedit(text)
}
}
#endif