初始提交: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,91 @@
//
// TerminalSelectionAnchor.swift
// libghostty-spm
//
import Foundation
enum TerminalSelectionAnchor {
/// Map a quicklook word + its top-left host-point coordinate back into
/// an `NSRange` inside the viewport text snapshot, suitable for direct
/// assignment to `UITextView.selectedRange`.
///
/// Strategy: derive `row` from `pointY / cellHeightPoints`; collect every
/// literal occurrence of `word` in that row; then use
/// `pointX / cellWidthPoints` as the expected UTF-16 column and pick the
/// match whose `location` is closest. This resolves substring ambiguity
/// (e.g. `catalog cat` long-pressed at the end picks the standalone
/// `cat`, not the prefix of `catalog`) without depending on word
/// boundaries which would fail for tokens like `/foo` whose first
/// character is a non-word character.
///
/// Units: `pointX/Y` and `cellWidth/HeightPoints` must all be host
/// points (not surface pixels). Callers are responsible for converting
/// `cellPixels / displayScale points` before invoking. Ghostty's
/// embedded API returns `tl_px_x/y` in host points, so passing them
/// through unchanged is correct.
///
/// Known limitation: when the target row contains CJK full-width
/// characters before the match, cell columns and UTF-16 offsets diverge
/// (CJK = 2 cells, 1 UTF-16 unit), so disambiguation between duplicates
/// may pick the wrong occurrence. ASCII-only scenarios are exact.
static func resolveRange(
in text: String,
word: String,
pointX: Double,
pointY: Double,
cellWidthPoints: Double,
cellHeightPoints: Double
) -> NSRange? {
guard !word.isEmpty else { return nil }
guard pointX.isFinite, pointY.isFinite,
cellWidthPoints.isFinite, cellHeightPoints.isFinite
else { return nil }
guard cellWidthPoints > 0, cellHeightPoints > 0 else { return nil }
guard pointX >= 0, pointY >= 0 else { return nil }
let rowDouble = pointY / cellHeightPoints
let columnDouble = pointX / cellWidthPoints
guard rowDouble.isFinite, columnDouble.isFinite,
rowDouble < Double(Int.max), columnDouble < Double(Int.max)
else { return nil }
let row = Int(rowDouble)
let expectedColumnUTF16 = Int(columnDouble)
let nsText = text as NSString
let lines = nsText.components(separatedBy: "\n")
guard row >= 0, row < lines.count else { return nil }
let line = lines[row] as NSString
let wordNS = word as NSString
var matches: [NSRange] = []
var searchLocation = 0
while searchLocation < line.length {
let searchRange = NSRange(
location: searchLocation,
length: line.length - searchLocation
)
let hit = line.range(of: word, options: .literal, range: searchRange)
if hit.location == NSNotFound { break }
matches.append(hit)
searchLocation = NSMaxRange(hit)
if wordNS.length == 0 { break }
}
guard !matches.isEmpty else { return nil }
let chosen = matches.min { lhs, rhs in
abs(lhs.location - expectedColumnUTF16) < abs(rhs.location - expectedColumnUTF16)
}!
var offset = 0
for i in 0 ..< row {
offset += (lines[i] as NSString).length + 1 // +1 for "\n"
}
let result = NSRange(location: offset + chosen.location, length: chosen.length)
guard NSMaxRange(result) <= nsText.length else { return nil }
return result
}
}

View File

@@ -0,0 +1,416 @@
//
// TerminalSurface.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import Foundation
import GhosttyKit
/// Thread-safe wrapper around `ghostty_surface_t`.
///
/// All access must happen on the main actor. The surface should be freed
/// explicitly via ``free()`` before the wrapper is deallocated; `deinit`
/// includes a safety net but relying on it is discouraged.
@MainActor
public final class TerminalSurface {
private var surface: ghostty_surface_t?
private var hasBeenFreed = false
init(_ surface: ghostty_surface_t) {
self.surface = surface
}
var rawValue: ghostty_surface_t? {
surface
}
// MARK: - Input
@discardableResult
func sendKeyEvent(_ event: ghostty_input_key_s) -> Bool {
guard let s = surface else {
TerminalDebugLog.log(.input, "surface key ignored: missing surface")
return false
}
let result = ghostty_surface_key(s, event)
TerminalDebugLog.log(
.input,
"surface key action=\(TerminalDebugLog.describe(event.action)) keycode=\(event.keycode) mods=0x\(String(event.mods.rawValue, radix: 16)) consumed=0x\(String(event.consumed_mods.rawValue, radix: 16)) text=\(terminalKeyText(event)) composing=\(event.composing) result=\(result)"
)
return result
}
@discardableResult
public func sendText(_ text: String) -> Bool {
guard let s = surface else {
TerminalDebugLog.log(.input, "surface text ignored: missing surface")
return false
}
TerminalDebugLog.log(
.input,
"surface text=\(TerminalDebugLog.describe(text))"
)
text.withCString { cStr in
ghostty_surface_text(s, cStr, UInt(text.utf8.count))
}
return true
}
@discardableResult
func sendMouseButton(
state: ghostty_input_mouse_state_e,
button: ghostty_input_mouse_button_e,
mods: ghostty_input_mods_e
) -> Bool {
guard let s = surface else {
TerminalDebugLog.log(.input, "surface mouse button ignored: missing surface")
return false
}
let result = ghostty_surface_mouse_button(s, state, button, mods)
TerminalDebugLog.log(
.input,
"surface mouseButton state=\(TerminalDebugLog.describe(state)) button=\(button.rawValue) mods=0x\(String(mods.rawValue, radix: 16)) result=\(result)"
)
return result
}
func sendMousePos(x: Double, y: Double, mods: ghostty_input_mods_e) {
guard let s = surface else {
TerminalDebugLog.log(.input, "surface mouse position ignored: missing surface")
return
}
TerminalDebugLog.log(
.input,
"surface mousePos x=\(String(format: "%.2f", x)) y=\(String(format: "%.2f", y)) mods=0x\(String(mods.rawValue, radix: 16))"
)
ghostty_surface_mouse_pos(s, x, y, mods)
}
func sendMouseScroll(x: Double, y: Double, mods: ghostty_input_scroll_mods_t) {
guard let s = surface else {
TerminalDebugLog.log(.input, "surface scroll ignored: missing surface")
return
}
TerminalDebugLog.log(
.input,
"surface scroll x=\(String(format: "%.2f", x)) y=\(String(format: "%.2f", y)) mods=0x\(String(mods, radix: 16))"
)
ghostty_surface_mouse_scroll(s, x, y, mods)
}
func preedit(_ text: String) {
guard let s = surface else {
TerminalDebugLog.log(.ime, "surface preedit ignored: missing surface")
return
}
TerminalDebugLog.log(.ime, "surface preedit=\(TerminalDebugLog.describe(text))")
text.withCString { cStr in
ghostty_surface_preedit(s, cStr, UInt(text.utf8.count))
}
}
// MARK: - Actions
/// Invoke a named Ghostty binding action.
///
/// Action names use the same syntax as Ghostty's `keybind` configuration,
/// such as `copy_to_clipboard` or `scroll_page_lines:-3`.
@discardableResult
public func performBindingAction(_ action: String) -> Bool {
guard let s = surface else {
TerminalDebugLog.log(.actions, "binding action ignored: missing surface")
return false
}
let result = action.withCString { cStr in
ghostty_surface_binding_action(s, cStr, UInt(action.utf8.count))
}
TerminalDebugLog.log(
.actions,
"binding action=\(TerminalDebugLog.describe(action)) result=\(result)"
)
return result
}
/// Jump the viewport by a number of shell prompts.
///
/// Negative offsets move toward older prompts and positive offsets move
/// toward newer prompts. This requires prompt markers from Ghostty shell
/// integration, or equivalent OSC 133 markers from a host-managed backend.
@discardableResult
public func jumpToPrompt(by offset: Int16) -> Bool {
performBindingAction("jump_to_prompt:\(offset)")
}
/// Reveal an absolute scrollback row, where zero is the first row.
@discardableResult
public func scrollToRow(_ row: UInt) -> Bool {
performBindingAction("scroll_to_row:\(row)")
}
// MARK: - Rendering
func draw() {
guard let s = surface else { return }
TerminalDebugLog.log(.render, "surface draw")
ghostty_surface_draw(s)
}
func refresh() {
guard let s = surface else { return }
TerminalDebugLog.log(.render, "surface refresh")
ghostty_surface_refresh(s)
}
func setSize(width: UInt32, height: UInt32) {
guard let s = surface else {
TerminalDebugLog.log(.metrics, "surface setSize ignored: missing surface")
return
}
TerminalDebugLog.log(.metrics, "surface setSize \(width)x\(height)")
ghostty_surface_set_size(s, width, height)
}
func setContentScale(x: Double, y: Double) {
guard let s = surface else {
TerminalDebugLog.log(.metrics, "surface contentScale ignored: missing surface")
return
}
TerminalDebugLog.log(
.metrics,
"surface contentScale x=\(String(format: "%.2f", x)) y=\(String(format: "%.2f", y))"
)
ghostty_surface_set_content_scale(s, x, y)
}
// MARK: - State
func setFocus(_ focused: Bool) {
guard let s = surface else { return }
TerminalDebugLog.log(.lifecycle, "surface focus=\(focused)")
ghostty_surface_set_focus(s, focused)
}
func setColorScheme(_ scheme: ghostty_color_scheme_e) {
guard let s = surface else { return }
TerminalDebugLog.log(.lifecycle, "surface colorScheme=\(scheme.rawValue)")
ghostty_surface_set_color_scheme(s, scheme)
}
func setOcclusion(_ visible: Bool) {
guard let s = surface else { return }
TerminalDebugLog.log(.lifecycle, "surface occlusion visible=\(visible)")
ghostty_surface_set_occlusion(s, visible)
}
// MARK: - Size Query
func size() -> TerminalGridMetrics? {
guard let s = surface else {
TerminalDebugLog.log(.metrics, "surface size query ignored: missing surface")
return nil
}
let metrics = TerminalGridMetrics(ghostty_surface_size(s))
TerminalDebugLog.log(.metrics, "surface size \(metrics.debugSummary)")
return metrics
}
// MARK: - Selection
struct SelectionResult {
let text: String
let offsetStart: UInt32
let offsetLength: UInt32
}
func hasSelection() -> Bool {
guard let s = surface else {
TerminalDebugLog.log(.input, "surface selection query ignored: missing surface")
return false
}
let result = ghostty_surface_has_selection(s)
TerminalDebugLog.log(.input, "surface hasSelection=\(result)")
return result
}
func readSelection() -> String? {
readSelectionResult()?.text
}
func readSelectionResult() -> SelectionResult? {
guard let s = surface else {
TerminalDebugLog.log(.input, "surface readSelection ignored: missing surface")
return nil
}
var out = ghostty_text_s()
guard ghostty_surface_read_selection(s, &out) else {
TerminalDebugLog.log(.input, "surface readSelection returned false")
return nil
}
defer { ghostty_surface_free_text(s, &out) }
guard let textPtr = out.text, out.text_len > 0 else {
TerminalDebugLog.log(.input, "surface readSelection empty")
return SelectionResult(
text: "",
offsetStart: out.offset_start,
offsetLength: out.offset_len
)
}
let bytes = UnsafeBufferPointer(start: textPtr, count: Int(out.text_len))
.map { UInt8(bitPattern: $0) }
let text = String(decoding: bytes, as: UTF8.self)
TerminalDebugLog.log(
.input,
"surface readSelection bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text)) offset=\(out.offset_start)+\(out.offset_len)"
)
return SelectionResult(
text: text,
offsetStart: out.offset_start,
offsetLength: out.offset_len
)
}
// MARK: - IME
func imePoint() -> (x: Double, y: Double, width: Double, height: Double) {
var x: Double = 0
var y: Double = 0
var w: Double = 0
var h: Double = 0
if let s = surface {
ghostty_surface_ime_point(s, &x, &y, &w, &h)
}
TerminalDebugLog.log(
.ime,
"surface imePoint x=\(String(format: "%.2f", x)) y=\(String(format: "%.2f", y)) width=\(String(format: "%.2f", w)) height=\(String(format: "%.2f", h))"
)
return (x, y, w, h)
}
// MARK: - Mouse Capture
var isMouseCaptured: Bool {
guard let s = surface else { return false }
return ghostty_surface_mouse_captured(s)
}
// MARK: - Quicklook Word (Apple-only)
#if canImport(UIKit) || canImport(AppKit)
struct QuicklookWordResult {
let word: String
let offsetStart: UInt32
let offsetLength: UInt32
// tl_px_x / tl_px_y are reported in host points (view coordinates),
// not surface pixels. Ghostty's embedded API receives mouse_pos in
// points and stores the cursor position * contentScale internally,
// then divides by contentScale when reporting selection coordinates
// back. Callers must convert cell pixel dimensions to points before
// dividing.
let pointX: Double
let pointY: Double
}
func quicklookWord() -> QuicklookWordResult? {
guard let s = surface else {
TerminalDebugLog.log(.input, "surface quicklookWord ignored: missing surface")
return nil
}
var out = ghostty_text_s()
guard ghostty_surface_quicklook_word(s, &out) else {
TerminalDebugLog.log(.input, "surface quicklookWord returned false")
return nil
}
defer { ghostty_surface_free_text(s, &out) }
let word: String
if let textPtr = out.text, out.text_len > 0 {
let bytes = UnsafeBufferPointer(start: textPtr, count: Int(out.text_len))
.map { UInt8(bitPattern: $0) }
word = String(decoding: bytes, as: UTF8.self)
} else {
word = ""
}
TerminalDebugLog.log(
.input,
"surface quicklookWord word=\(TerminalDebugLog.describe(word)) offset=\(out.offset_start)+\(out.offset_len) pointX=\(String(format: "%.2f", out.tl_px_x)) pointY=\(String(format: "%.2f", out.tl_px_y))"
)
return QuicklookWordResult(
word: word,
offsetStart: out.offset_start,
offsetLength: out.offset_len,
pointX: out.tl_px_x,
pointY: out.tl_px_y
)
}
func selectionContainsQuicklookWord() -> Bool {
guard let selected = readSelectionResult(),
let word = quicklookWord(),
!word.word.isEmpty,
word.offsetLength > 0
else { return false }
let selectionStart = UInt64(selected.offsetStart)
let selectionEnd = selectionStart + UInt64(selected.offsetLength)
let wordStart = UInt64(word.offsetStart)
let wordEnd = wordStart + UInt64(word.offsetLength)
let contains = wordStart >= selectionStart && wordEnd <= selectionEnd
TerminalDebugLog.log(
.input,
"surface selectionContainsQuicklookWord=\(contains) selection=\(selected.offsetStart)+\(selected.offsetLength) word=\(word.offsetStart)+\(word.offsetLength)"
)
return contains
}
#endif
// MARK: - Process
/// PID of the pty's foreground process group (`tcgetpgrp(pty)`). When the
/// user runs a program in the pty this is that program's pid, so hosts can
/// correlate a surface with an external process list. Ghostty returns 0
/// when the surface has no process yet surfaced here as nil.
var foregroundPid: pid_t? {
guard let s = surface else { return nil }
let pid = ghostty_surface_foreground_pid(s)
return pid == 0 ? nil : pid_t(pid)
}
/// Name of the pty's controlling tty (e.g. `/dev/ttys004`), or nil when the
/// surface has no process yet. Useful as a cross-check for ``foregroundPid``.
var ttyName: String? {
guard let s = surface else { return nil }
let str = ghostty_surface_tty_name(s)
defer { ghostty_string_free(str) }
guard let ptr = str.ptr, str.len > 0 else { return nil }
return String(
decoding: UnsafeRawBufferPointer(start: ptr, count: Int(str.len)),
as: UTF8.self
)
}
// MARK: - Lifecycle
func free() {
guard !hasBeenFreed, let s = surface else { return }
TerminalDebugLog.log(.lifecycle, "surface free")
hasBeenFreed = true
surface = nil
ghostty_surface_free(s)
}
deinit {
// Surface should be freed explicitly via free() before deinit.
// The deinit safety net is intentionally removed because
// Swift 6 strict concurrency prevents accessing @MainActor
// state from nonisolated deinit.
}
}
private func terminalKeyText(_ event: ghostty_input_key_s) -> String {
guard let text = event.text else { return "nil" }
return TerminalDebugLog.describe(String(cString: text))
}

View File

@@ -0,0 +1,18 @@
//
// TerminalSurfaceContext.swift
// libghostty-spm
//
import GhosttyKit
public enum TerminalSurfaceContext: Sendable, Equatable {
case window
case split
var ghosttyValue: ghostty_surface_context_e {
switch self {
case .window: GHOSTTY_SURFACE_CONTEXT_WINDOW
case .split: GHOSTTY_SURFACE_CONTEXT_SPLIT
}
}
}

View File

@@ -0,0 +1,409 @@
//
// TerminalSurfaceCoordinator.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import Foundation
import GhosttyKit
import MSDisplayLink
/// Shared terminal state and logic used by both UIKit and AppKit views.
///
/// Platform views own a `TerminalSurfaceCoordinator` instance and set platform-specific
/// hooks via closures. The core handles surface lifecycle, metrics
/// synchronization, and frame rendering via scheduled wakeups.
@MainActor
final class TerminalSurfaceCoordinator {
weak var delegate: (any TerminalSurfaceViewDelegate)? {
didSet { bridge.delegate = delegate }
}
var controller: TerminalController? {
didSet {
guard controller !== oldValue else { return }
rebuildIfReady(removingBridgeFrom: oldValue)
}
}
var configuration: TerminalSurfaceOptions = .init() {
didSet {
guard !configuration.isEquivalent(to: oldValue) else { return }
rebuildIfReady()
}
}
var surface: TerminalSurface?
let bridge = TerminalCallbackBridge()
// MARK: - Platform Hooks
var isAttached: () -> Bool = { false }
var scaleFactor: () -> Double = { 2.0 }
var viewSize: () -> (width: Double, height: Double) = { (0, 0) }
var platformSetup: ((inout ghostty_surface_config_s) -> Void)?
var onMetricsUpdate: (() -> Void)?
var onCellSizeDidChange: (() -> Void)?
/// Called after every display-link render (`tick`).
///
/// When `synchronizeMetrics` sends a new pixel size to ghostty via
/// `setSize`, the underlying IOSurface is not rebuilt synchronously.
/// Until the next full render pass ghostty still uses the **old**
/// IOSurface, so it derives an incorrect `contentsScale` for the
/// IOSurfaceLayer (e.g. old-pixel-height / new-point-height 4.62
/// instead of the expected 3.0). This causes a visible "jump" on
/// every layout change (keyboard show/hide, rotation, color-scheme
/// toggle, etc.).
///
/// Platform views use this hook to silently enforce the correct
/// `contentsScale` and `frame` on sublayers after each render,
/// correcting any drift introduced by ghostty within a single frame.
var onPostRender: (() -> Void)?
private var lastMetrics: TerminalViewportMetrics?
private var isDisplayVisible = true
private var isApplicationActive = true
private var isSurfaceFocused = false
private var pendingImmediateTick = true
private var lastTickTimestamp: TimeInterval = 0
private var tickScheduled = false
init() {
bridge.onCellSizeChange = { [weak self] width, height in
self?.handleCellSizeChange(width: width, height: height)
}
bridge.onRenderRequest = { [weak self] in
self?.requestImmediateTick()
}
}
func requestImmediateTick() {
pendingImmediateTick = true
scheduleTickIfNeeded()
}
func startDisplayLink() {
scheduleTickIfNeeded()
}
func stopDisplayLink() {
tickScheduled = false
}
// MARK: - Surface Lifecycle
func rebuildIfReady(removingBridgeFrom previousController: TerminalController? = nil) {
tearDownSurface(removingBridgeFrom: previousController ?? controller)
guard let controller else {
TerminalDebugLog.log(.lifecycle, "surface rebuild skipped: missing controller")
return
}
guard isAttached() else {
TerminalDebugLog.log(.lifecycle, "surface rebuild skipped: view detached")
return
}
guard hasValidViewSize else {
let size = viewSize()
TerminalDebugLog.log(
.lifecycle,
"surface rebuild skipped: invalid view size=\(String(format: "%.2f", size.width))x\(String(format: "%.2f", size.height))"
)
return
}
let scale = scaleFactor()
TerminalDebugLog.log(
.lifecycle,
"surface rebuild scale=\(String(format: "%.2f", scale)) \(configuration.debugSummary)"
)
let rawSurface = controller.createSurface(
bridge: bridge,
configuration: configuration,
platformSetup: { [self] config in
platformSetup?(&config)
config.scale_factor = scale
}
)
guard let rawSurface else {
TerminalDebugLog.log(.lifecycle, "surface rebuild failed")
return
}
bridge.rawSurface = rawSurface
let newSurface = TerminalSurface(rawSurface)
surface = newSurface
newSurface.setOcclusion(effectiveSurfaceVisible)
controller.shouldProcessWakeup = { [weak self] in
self?.canRenderFrame == true
}
controller.onWakeup = { [weak self] in
self?.requestImmediateTick()
}
TerminalDebugLog.log(.lifecycle, "surface rebuild succeeded")
(delegate as? any TerminalSurfaceLifecycleDelegate)?
.terminalDidAttachSurface(newSurface)
synchronizeMetrics()
requestImmediateTick()
}
// MARK: - Metrics
func synchronizeMetrics() {
guard let surface else {
TerminalDebugLog.log(.metrics, "synchronizeMetrics skipped: missing surface")
return
}
let scale = scaleFactor()
let size = viewSize()
guard size.width > 0, size.height > 0 else {
TerminalDebugLog.log(
.metrics,
"synchronizeMetrics skipped: invalid view size=\(String(format: "%.2f", size.width))x\(String(format: "%.2f", size.height))"
)
return
}
let pixelWidth = UInt32((size.width * scale).rounded(.down))
let pixelHeight = UInt32((size.height * scale).rounded(.down))
guard pixelWidth > 0, pixelHeight > 0 else {
TerminalDebugLog.log(
.metrics,
"synchronizeMetrics skipped: invalid pixel size=\(pixelWidth)x\(pixelHeight)"
)
return
}
TerminalDebugLog.log(
.metrics,
"sync view=\(String(format: "%.2f", size.width))x\(String(format: "%.2f", size.height)) scale=\(String(format: "%.2f", scale)) pixels=\(pixelWidth)x\(pixelHeight)"
)
surface.setContentScale(x: scale, y: scale)
surface.setSize(width: pixelWidth, height: pixelHeight)
guard let surfaceSize = surface.size(),
surfaceSize.columns > 0, surfaceSize.rows > 0
else {
TerminalDebugLog.log(.metrics, "sync missing grid metrics after resize")
onMetricsUpdate?()
return
}
let metrics = TerminalViewportMetrics(surfaceSize: surfaceSize, scale: scale)
guard metrics != lastMetrics else {
TerminalDebugLog.log(
.metrics,
"sync unchanged \(metrics.debugSummary)"
)
onMetricsUpdate?()
return
}
lastMetrics = metrics
TerminalDebugLog.log(.metrics, "sync updated \(metrics.debugSummary)")
configuration.inMemorySession?.updateViewport(surfaceSize)
if let delegate = delegate as? any TerminalSurfaceGridResizeDelegate {
delegate.terminalDidResize(surfaceSize)
} else if let delegate = delegate as? any TerminalSurfaceResizeDelegate {
delegate.terminalDidResize(
columns: Int(surfaceSize.columns),
rows: Int(surfaceSize.rows)
)
}
onMetricsUpdate?()
}
func fitToSize() {
if surface == nil {
rebuildIfReady()
} else {
synchronizeMetrics()
}
if surface != nil {
requestImmediateTick()
}
}
func setDisplayVisible(_ visible: Bool) {
guard isDisplayVisible != visible else {
surface?.setOcclusion(effectiveSurfaceVisible)
return
}
isDisplayVisible = visible
surface?.setOcclusion(effectiveSurfaceVisible)
if canRenderFrame {
requestImmediateTick()
} else {
stopDisplayLink()
}
}
func setApplicationActive(_ active: Bool) {
guard isApplicationActive != active else {
if active {
renderImmediately()
} else {
stopDisplayLink()
}
return
}
isApplicationActive = active
surface?.setOcclusion(effectiveSurfaceVisible)
if active {
synchronizeMetrics()
renderImmediately()
} else {
stopDisplayLink()
}
}
// MARK: - Frame Rendering
func tick(context: DisplayLinkCallbackContext) {
guard shouldRenderFrame(at: context.timestamp) else {
return
}
pendingImmediateTick = false
lastTickTimestamp = context.timestamp
TerminalDebugLog.log(.render, "tick")
controller?.tick()
surface?.refresh()
surface?.draw()
onPostRender?()
}
// MARK: - Focus
func setFocus(_ focused: Bool) {
isSurfaceFocused = focused
requestImmediateTick()
TerminalDebugLog.log(.lifecycle, "focus=\(focused)")
surface?.setFocus(focused)
(delegate as? any TerminalSurfaceFocusDelegate)?
.terminalDidChangeFocus(focused)
}
// MARK: - Cleanup
func freeSurface() {
TerminalDebugLog.log(.lifecycle, "free surface")
tearDownSurface(removingBridgeFrom: controller)
}
deinit {
// `@MainActor` classes have a nonisolated deinit by default, but
// `tearDownSurface` calls methods on other main-actor types (surface,
// bridge, controller). We rely on deinit running synchronously with
// exclusive access; assume main-actor isolation so teardown can run
// inline without crossing isolation.
MainActor.assumeIsolated {
tearDownSurface(removingBridgeFrom: controller)
}
}
private func tearDownSurface(removingBridgeFrom controller: TerminalController?) {
TerminalDebugLog.log(.lifecycle, "tear down surface")
tickScheduled = false
if let session = configuration.inMemorySession {
session.clearSurface(ifMatches: surface?.rawValue)
}
controller?.onWakeup = nil
controller?.shouldProcessWakeup = nil
bridge.rawSurface = nil
let hadSurface = surface != nil
surface?.setFocus(false)
surface?.free()
surface = nil
lastMetrics = nil
pendingImmediateTick = true
lastTickTimestamp = 0
controller?.remove(bridge)
if hadSurface {
(delegate as? any TerminalSurfaceLifecycleDelegate)?
.terminalDidDetachSurface()
}
}
private func handleCellSizeChange(width: UInt32, height: UInt32) {
TerminalDebugLog.log(
.metrics,
"cell size changed width=\(width) height=\(height)"
)
synchronizeMetrics()
requestImmediateTick()
onCellSizeDidChange?()
}
private func shouldRenderFrame(at _: TimeInterval) -> Bool {
guard canRenderFrame else {
return false
}
return pendingImmediateTick || lastTickTimestamp == 0
}
private func scheduleTickIfNeeded() {
guard canRenderFrame else {
tickScheduled = false
return
}
guard !tickScheduled else {
return
}
tickScheduled = true
TerminalDebugLog.log(.lifecycle, "tick scheduled")
DispatchQueue.main.async { [weak self] in
guard let self else { return }
tickScheduled = false
let timestamp = Self.monotonicTimestamp()
tick(
context: .init(
duration: 0,
timestamp: timestamp,
targetTimestamp: timestamp
)
)
}
}
private static func monotonicTimestamp() -> TimeInterval {
ProcessInfo.processInfo.systemUptime
}
private var effectiveSurfaceVisible: Bool {
isDisplayVisible && isApplicationActive
}
private var canRenderFrame: Bool {
effectiveSurfaceVisible && isAttached()
}
private var hasValidViewSize: Bool {
let size = viewSize()
return size.width > 0 && size.height > 0
}
private func renderImmediately() {
guard canRenderFrame else {
tickScheduled = false
return
}
pendingImmediateTick = true
tickScheduled = false
let timestamp = Self.monotonicTimestamp()
tick(
context: .init(
duration: 0,
timestamp: timestamp,
targetTimestamp: timestamp
)
)
}
}

View File

@@ -0,0 +1,48 @@
//
// TerminalSurfaceOptions.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import GhosttyKit
public struct TerminalSurfaceOptions: Sendable {
public var backend: TerminalSessionBackend
public var fontSize: Float?
public var workingDirectory: String?
/// Extra environment variables set in the child process spawned for this
/// surface (exec backend). Passed through `ghostty_surface_config_s.env_vars`;
/// every process launched from the surface's shell inherits them, which lets
/// embedding hosts tag a surface (e.g. `MYAPP_PANE=<uuid>`) and correlate
/// externally observed processes back to it.
public var envVars: [String: String]
public var context: TerminalSurfaceContext
public init(
backend: TerminalSessionBackend = .exec,
fontSize: Float? = nil,
workingDirectory: String? = nil,
envVars: [String: String] = [:],
context: TerminalSurfaceContext = .window
) {
self.backend = backend
self.fontSize = fontSize
self.workingDirectory = workingDirectory
self.envVars = envVars
self.context = context
}
func isEquivalent(to other: TerminalSurfaceOptions) -> Bool {
fontSize == other.fontSize
&& workingDirectory == other.workingDirectory
&& envVars == other.envVars
&& context == other.context
&& backend.isEquivalent(to: other.backend)
}
var inMemorySession: InMemoryTerminalSession? {
guard case let .inMemory(session) = backend else { return nil }
return session
}
}

View File

@@ -0,0 +1,82 @@
//
// TerminalSurfaceView.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import SwiftUI
public struct TerminalSurfaceView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var context: TerminalViewState
let focusBinding: TerminalFocusBinding?
public init(context: TerminalViewState) {
self.context = context
focusBinding = nil
}
init(
context: TerminalViewState,
focusBinding: TerminalFocusBinding?
) {
self.context = context
self.focusBinding = focusBinding
}
public var body: some View {
TerminalViewRepresentable(
context: context,
controller: context.controller,
configuration: context.configuration,
focusBinding: focusBinding
)
.background(.clear)
.onChange(of: colorScheme) { newScheme in
context.adopt(colorScheme: newScheme)
}
.onAppear {
context.adopt(colorScheme: colorScheme)
}
}
public func terminalFocused(
_ condition: FocusState<Bool>.Binding
) -> TerminalSurfaceView {
TerminalSurfaceView(
context: context,
focusBinding: .bool(condition)
)
}
public func terminalFocused<Value: Hashable>(
_ binding: FocusState<Value?>.Binding,
equals value: Value
) -> TerminalSurfaceView {
TerminalSurfaceView(
context: context,
focusBinding: .optional(binding, equals: value)
)
}
public func terminalFocusOnAppear(
_ condition: FocusState<Bool>.Binding
) -> some View {
terminalFocused(condition)
.onAppear {
condition.wrappedValue = true
}
}
public func terminalFocusOnAppear<Value: Hashable>(
_ binding: FocusState<Value?>.Binding,
equals value: Value
) -> some View {
terminalFocused(binding, equals: value)
.onAppear {
binding.wrappedValue = value
}
}
}

View File

@@ -0,0 +1,168 @@
//
// TerminalSurfaceViewDelegate.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import CoreGraphics
import Foundation
import GhosttyKit
@MainActor
public protocol TerminalSurfaceViewDelegate: AnyObject {}
@MainActor
public protocol TerminalSurfaceTitleDelegate: TerminalSurfaceViewDelegate {
func terminalDidChangeTitle(_ title: String)
}
@MainActor
public protocol TerminalSurfaceGridResizeDelegate: TerminalSurfaceViewDelegate {
func terminalDidResize(_ size: TerminalGridMetrics)
}
@MainActor
public protocol TerminalSurfaceResizeDelegate: TerminalSurfaceViewDelegate {
func terminalDidResize(columns: Int, rows: Int)
}
@MainActor
public protocol TerminalSurfaceFocusDelegate: TerminalSurfaceViewDelegate {
func terminalDidChangeFocus(_ focused: Bool)
}
@MainActor
public protocol TerminalSurfaceBellDelegate: TerminalSurfaceViewDelegate {
func terminalDidRingBell()
}
@MainActor
public protocol TerminalSurfaceCloseDelegate: TerminalSurfaceViewDelegate {
func terminalDidClose(processAlive: Bool)
}
// MARK: - Extended action delegates
/// State of an OSC 9;4 / DECSET progress report.
public enum TerminalProgressState: Sendable {
case remove
case set
case error
case indeterminate
case pause
init?(_ raw: ghostty_action_progress_report_state_e) {
switch raw {
case GHOSTTY_PROGRESS_STATE_REMOVE: self = .remove
case GHOSTTY_PROGRESS_STATE_SET: self = .set
case GHOSTTY_PROGRESS_STATE_ERROR: self = .error
case GHOSTTY_PROGRESS_STATE_INDETERMINATE: self = .indeterminate
case GHOSTTY_PROGRESS_STATE_PAUSE: self = .pause
default: return nil
}
}
}
/// OSC 9;4 progress report (state + 0-100 percent, nil percent when the
/// emitter didn't provide one e.g. INDETERMINATE / REMOVE).
@MainActor
public protocol TerminalSurfaceProgressReportDelegate: TerminalSurfaceViewDelegate {
func terminalDidReportProgress(state: TerminalProgressState, percent: Int?)
}
/// Fires when a shell-integration-aware command exits. `exitCode` is nil
/// when not reported; `duration` is the wall clock in nanoseconds.
@MainActor
public protocol TerminalSurfaceCommandFinishedDelegate: TerminalSurfaceViewDelegate {
func terminalDidFinishCommand(exitCode: Int?, durationNanos: UInt64)
}
/// OSC 9 (iTerm2) / OSC 777 (rxvt-unicode) desktop notification.
/// Empty title/body surface as empty strings rather than nil.
@MainActor
public protocol TerminalSurfaceDesktopNotificationDelegate: TerminalSurfaceViewDelegate {
func terminalDidRequestDesktopNotification(title: String, body: String)
}
public enum TerminalOpenURLKind: Sendable {
case unknown
case text
case html
init(_ raw: ghostty_action_open_url_kind_e) {
switch raw {
case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: self = .text
case GHOSTTY_ACTION_OPEN_URL_KIND_HTML: self = .html
default: self = .unknown
}
}
}
/// User activated (cmd-clicked) a hyperlink inside the terminal grid.
@MainActor
public protocol TerminalSurfaceOpenURLDelegate: TerminalSurfaceViewDelegate {
func terminalDidRequestOpenURL(_ url: String, kind: TerminalOpenURLKind)
}
/// Mouse hovered over a recognized hyperlink. nil = hover ended / link lost.
@MainActor
public protocol TerminalSurfaceHoverLinkDelegate: TerminalSurfaceViewDelegate {
func terminalDidUpdateHoverLink(_ url: String?)
}
/// OSC 7 working-directory update.
@MainActor
public protocol TerminalSurfacePwdDelegate: TerminalSurfaceViewDelegate {
func terminalDidChangeWorkingDirectory(_ path: String)
}
/// Scrollbar geometry reported by the terminal, in rows: `offset` rows are
/// scrolled off above the viewport, `len` rows are visible, out of `total`
/// rows of content (scrollback + screen).
public struct TerminalScrollbar: Equatable, Sendable {
public let total: UInt64
public let offset: UInt64
public let len: UInt64
public init(total: UInt64, offset: UInt64, len: UInt64) {
self.total = total
self.offset = offset
self.len = len
}
}
/// The scrollbar geometry changed (the viewport scrolled or the content grew).
@MainActor
public protocol TerminalSurfaceScrollbarDelegate: TerminalSurfaceViewDelegate {
func terminalDidUpdateScrollbar(_ scrollbar: TerminalScrollbar)
}
/// User long-pressed to request a selection-page presentation.
public struct TerminalTextSelectionRequest: Sendable {
/// Viewport text snapshot. Lines separated by `\n`.
public let text: String
/// Recommended pre-selection range in UTF-16 units, suitable for direct
/// assignment to `UITextView.selectedRange`. `nil` means the host should
/// `selectAll` instead.
public let anchorRange: NSRange?
/// Long-press point in the terminal view's coordinate space (points).
/// Hosts may use this as a popover anchor.
public let sourcePoint: CGPoint
}
@MainActor
public protocol TerminalSurfaceTextSelectionRequestDelegate: TerminalSurfaceViewDelegate {
func terminalDidRequestTextSelection(_ request: TerminalTextSelectionRequest)
}
/// Notifies a delegate when the underlying ``TerminalSurface`` is created or
/// torn down. Useful when a consumer needs surface-level APIs (e.g.
/// ``TerminalSurface/sendText(_:)``) reachable from outside the platform view.
@MainActor
public protocol TerminalSurfaceLifecycleDelegate: TerminalSurfaceViewDelegate {
func terminalDidAttachSurface(_ surface: TerminalSurface)
func terminalDidDetachSurface()
}