Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/build-android-libs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ on:
ghostty_commit:
description: ghostty commit to build (C ABI is not yet stable — always pin)
required: true
default: b0947378349eff70f7030dda0e6d022fae1e6fbd
default: 3c1ef5b32fc5ea6b93d28493fabf193f595139cf

permissions:
contents: write

env:
# ghostty's requireZig hard-pins the minor version.
ZIG_VERSION: 0.15.2
ZIG_VERSION: 0.16.0
NDK_VERSION: 27.1.12297006
# Symbols-only Nerd Font bundled as an AAR asset for PUA glyph cells.
NERD_FONTS_VERSION: v3.4.0
NERD_FONTS_SHA256: 8e617904b980fe3648a4b116808788fe50c99d2d495376cb7c0badbd8a564c47
NERD_FONTS_VERSION: v3.5.1
NERD_FONTS_SHA256: fdca3682534f6f65e1ccb2345b0362ccf67d9b8eca7c8025330946e93e2473bc

jobs:
build:
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ jobs:
with:
node-version: 26

- uses: actions/setup-java@v5
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17

- uses: gradle/actions/setup-gradle@v5
- uses: gradle/actions/setup-gradle@v6
with:
# v6's cache is a separate proprietary component. Keep CI on the
# MIT-licensed action and skip that cache.
cache-disabled: true

# postinstall fetches the pinned libghostty-vt vendor tarball the
# CMake build links against.
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@

### 💡 Others

- Dependency bump: libghostty-spm 1.3.1 → 1.5.20260903 (XCFramework
`upstream.c4e16970a803`, ghostty `c4e16970`), MSDisplayLink 2.1.0 → 2.2.0,
libghostty-vt to ghostty `3c1ef5b` (Zig 0.16.0, Nerd Fonts v3.5.1).
Android JNI follows the new vt C API (`ghostty_terminal_new` cols/rows,
mode query via `GHOSTTY_TERMINAL_DATA_MODE`, colors via
`GHOSTTY_RENDER_STATE_DATA_COLORS`). Expo SDK 57.0.19 / React Native
0.86.3, ESLint 10.9.1 and typescript-eslint 8.69.0. `tsc` is TypeScript
7.0.2 (`@typescript/native`); the `typescript` package is
`@typescript/typescript6` so ESLint still has a compiler API (TypeScript
7 has none until 7.1).
- iOS: `clipboard-write = ask` so OSC 52 cannot silently replace the
system pasteboard. User paste still proceeds; program clipboard
read/write is denied until a host supplies confirmation UI.
- First automated test suites: vitest covers the TerminalView imperative
queue (the 0.8.1 mount-race contract), and JUnit covers the Android
snapshot wire format, cell color resolution, and the sticky-modifier state
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ dependencies this layer disappears in favor of the upstream package.

On Android, `android/vendor/` holds per-ABI `libghostty-vt.a` static
libraries plus the matching C headers, cross-compiled from a pinned ghostty
commit (Zig 0.15.2 + NDK r27); `vendor-manifest.json` pins the tarball
commit (Zig 0.16.0 + NDK r27); `vendor-manifest.json` pins the tarball
checksum. A thin JNI shim (`android/src/main/cpp/ghostty_jni.cpp`) exposes
the terminal + render-state loop to Kotlin, which paints the grid with
Canvas/Skia (`GhosttyTerminalView.kt`).
Expand Down
21 changes: 11 additions & 10 deletions android/src/main/cpp/ghostty_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -268,12 +268,9 @@ JNIEXPORT jlong JNICALL
Java_expo_modules_libghostty_GhosttyVt_nativeCreate(
JNIEnv*, jobject, jint cols, jint rows, jlong maxScrollback) {
auto* session = new Session();
GhosttyTerminalOptions opts{};
opts.cols = static_cast<uint16_t>(cols);
opts.rows = static_cast<uint16_t>(rows);
opts.max_scrollback = static_cast<size_t>(maxScrollback);

if (ghostty_terminal_new(nullptr, &session->term, opts) != GHOSTTY_SUCCESS ||
if (ghostty_terminal_new(nullptr, &session->term, static_cast<uint16_t>(cols),
static_cast<uint16_t>(rows)) != GHOSTTY_SUCCESS ||
ghostty_render_state_new(nullptr, &session->renderState) != GHOSTTY_SUCCESS ||
ghostty_render_state_row_iterator_new(nullptr, &session->rowIter) != GHOSTTY_SUCCESS ||
ghostty_render_state_row_cells_new(nullptr, &session->cells) != GHOSTTY_SUCCESS ||
Expand All @@ -284,6 +281,9 @@ Java_expo_modules_libghostty_GhosttyVt_nativeCreate(
return 0;
}

const size_t maxLines = static_cast<size_t>(maxScrollback);
ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES, &maxLines);

ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_USERDATA, session);
ghostty_terminal_set(session->term, GHOSTTY_TERMINAL_OPT_WRITE_PTY,
reinterpret_cast<const void*>(&writePtyCallback));
Expand Down Expand Up @@ -487,9 +487,8 @@ Java_expo_modules_libghostty_GhosttyVt_nativeSnapshot(
ghostty_render_state_get(session->renderState, GHOSTTY_RENDER_STATE_DATA_COLS, &cols);
ghostty_render_state_get(session->renderState, GHOSTTY_RENDER_STATE_DATA_ROWS, &rows);

GhosttyRenderStateColors colors{};
colors.size = sizeof(colors);
ghostty_render_state_colors_get(session->renderState, &colors);
GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors);
ghostty_render_state_get(session->renderState, GHOSTTY_RENDER_STATE_DATA_COLORS, &colors);

bool cursorVisible = false;
bool cursorBlinking = false;
Expand Down Expand Up @@ -814,8 +813,10 @@ Java_expo_modules_libghostty_GhosttyVt_nativeEncodePaste(
env->GetByteArrayRegion(data, 0, len, reinterpret_cast<jbyte*>(input.data()));
}

bool bracketed = false;
ghostty_terminal_mode_get(session->term, GHOSTTY_MODE_BRACKETED_PASTE, &bracketed);
GhosttyTerminalModeConfig modeConfig{};
modeConfig.mode = GHOSTTY_MODE_BRACKETED_PASTE;
ghostty_terminal_get(session->term, GHOSTTY_TERMINAL_DATA_MODE, &modeConfig);
const bool bracketed = modeConfig.value;

// Bracketed wrapping adds 12 bytes; stripping/CR replacement is 1:1.
std::vector<char> out(input.size() + 16);
Expand Down
9 changes: 8 additions & 1 deletion eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,11 @@ const { defineConfig } = require('eslint/config');
const universe = require('eslint-config-universe/flat/native');
const universeWeb = require('eslint-config-universe/flat/web');

module.exports = defineConfig([{ ignores: ['build'] }, ...universe, ...universeWeb]);
module.exports = defineConfig([
{ ignores: ['build'] },
...universe,
...universeWeb,
// eslint-plugin-react 7.37.5 is not ESLint 10-ready. Pinning the React
// version skips detectReactVersion(), which still calls context.getFilename().
{ settings: { react: { version: '19.2' } } },
]);
12 changes: 6 additions & 6 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"expo": "~57.0.6",
"react": "19.2.3",
"react-native": "0.86.0"
"expo": "~57.0.19",
"react": "19.2.8",
"react-native": "0.86.3"
},
"devDependencies": {
"@types/react": "~19.2.2",
"babel-preset-expo": "^57.0.3",
"typescript": "~6.0.3"
"@types/react": "~19.2.18",
"babel-preset-expo": "^57.0.10",
"typescript": "~7.0.2"
},
"scripts": {
"start": "expo start",
Expand Down
1 change: 1 addition & 0 deletions ios/ExpoLibghostty.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ Pod::Spec.new do |s|

# Module sources only — the vendored pods own everything under vendor/.
s.source_files = '*.{h,m,mm,swift,hpp,cpp}'
s.exclude_files = 'GhosttyTerminalBundle+CocoaPods.swift'
s.resource_bundles = { 'ExpoLibghostty_privacy' => 'privacy/ExpoLibghostty/PrivacyInfo.xcprivacy' }
end
14 changes: 13 additions & 1 deletion ios/ExpoLibghosttyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ class ExpoLibghosttyView: ExpoView {
self.session = session
// Without a controller the coordinator never builds a surface
// ("surface rebuild skipped: missing controller").
// OSC 52 writes must ask; the default `clipboard-write = allow`
// would let PTY output silently replace the system pasteboard.
_ = TerminalController.shared.setTerminalConfiguration(
TerminalConfiguration().custom("clipboard-write", "ask")
)
terminalView.controller = TerminalController.shared
terminalView.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
terminalView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
Expand Down Expand Up @@ -96,7 +101,8 @@ class ExpoLibghosttyView: ExpoView {

// Terminal effects (OSC escapes) surfaced as component events.
extension ExpoLibghosttyView: TerminalSurfaceBellDelegate, TerminalSurfaceTitleDelegate,
TerminalSurfacePwdDelegate {
TerminalSurfacePwdDelegate, TerminalSurfaceClipboardConfirmationDelegate
{
func terminalDidRingBell() {
onBell([:])
}
Expand All @@ -108,4 +114,10 @@ extension ExpoLibghosttyView: TerminalSurfaceBellDelegate, TerminalSurfaceTitleD
func terminalDidChangeWorkingDirectory(_ path: String) {
onDirectoryChange(["path": path])
}

func terminalDidRequestClipboardConfirmation(_ request: TerminalClipboardConfirmationRequest) {
// User-started paste can proceed. A program's OSC 52 read/write is
// denied until a host wires its own confirmation UI.
request.respond(allow: request.kind == .paste)
}
}
2 changes: 1 addition & 1 deletion ios/GhosttyKit.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# scripts/download-xcframework.mjs fetches (checksum-pinned) at install time.
Pod::Spec.new do |s|
s.name = 'GhosttyKit'
s.version = '1.3.1'
s.version = '1.5.20260903'
s.summary = "Ghostty's libghostty C API for Apple platforms (vendored by expo-libghostty)."
s.author = { 'Lakr233' => 'https://github.com/Lakr233' }
s.homepage = 'https://github.com/Lakr233/libghostty-spm'
Expand Down
14 changes: 11 additions & 3 deletions ios/GhosttyTerminal.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Swift wrapper layer: native terminal views, input/IME handling, display link.
Pod::Spec.new do |s|
s.name = 'GhosttyTerminal'
s.version = '1.3.1'
s.version = '1.5.20260903'
s.summary = 'Ghostty-powered native terminal view (vendored by expo-libghostty).'
s.author = { 'Lakr233' => 'https://github.com/Lakr233' }
s.homepage = 'https://github.com/Lakr233/libghostty-spm'
Expand All @@ -16,6 +16,14 @@ Pod::Spec.new do |s|
s.dependency 'GhosttyKit'
s.dependency 'MSDisplayLink'

s.source_files = 'vendor/GhosttyTerminal/**/*.swift'
s.resource_bundles = { 'GhosttyTerminal_privacy' => 'privacy/GhosttyTerminal/PrivacyInfo.xcprivacy' }
s.source_files = 'vendor/GhosttyTerminal/**/*.swift', 'GhosttyTerminalBundle+CocoaPods.swift'
# SPM generates Bundle.module for Resources/{Ghostty,terminfo}; CocoaPods
# needs an explicit resource bundle plus the Bundle.module shim.
s.resource_bundles = {
'GhosttyTerminal_privacy' => 'privacy/GhosttyTerminal/PrivacyInfo.xcprivacy',
'GhosttyTerminal' => [
'vendor/GhosttyTerminal/Resources/Ghostty',
'vendor/GhosttyTerminal/Resources/terminfo',
],
}
end
19 changes: 19 additions & 0 deletions ios/GhosttyTerminalBundle+CocoaPods.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

#if !SWIFT_PACKAGE
// SPM synthesizes Bundle.module from Package.swift resources. CocoaPods
// does not, so the vendored GhosttyRuntimeResources lookups need this
// shim. The GhosttyTerminal resource bundle holds Resources/Ghostty and
// Resources/terminfo (shell integration + compiled terminfo).
extension Bundle {
static var module: Bundle {
let host = Bundle(for: TerminalController.self)
if let url = host.url(forResource: "GhosttyTerminal", withExtension: "bundle"),
let bundle = Bundle(url: url)
{
return bundle
}
return host
}
}
#endif
2 changes: 1 addition & 1 deletion ios/MSDisplayLink.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Upstream ships SPM only; this pod exists because CocoaPods cannot consume SPM packages.
Pod::Spec.new do |s|
s.name = 'MSDisplayLink'
s.version = '2.1.0'
s.version = '2.2.0'
s.summary = 'Cross-platform DisplayLink (vendored by expo-libghostty).'
s.author = { 'Lakr233' => 'https://github.com/Lakr233' }
s.homepage = 'https://github.com/Lakr233/MSDisplayLink'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Darwin
import Foundation

/// Runtime assets required by Ghostty's exec backend.
///
/// The package owns these assets and always points libghostty at this immutable
/// bundle location before the C runtime initializes. User-level Ghostty
/// resources and configuration are never consulted.
public enum GhosttyRuntimeResources {
/// The package-bundled Ghostty resource directory.
///
/// Ghostty expects shell integration below this directory and its compiled
/// terminfo database in a sibling `terminfo` directory.
public static var directoryURL: URL? {
Bundle.module.url(forResource: "Ghostty", withExtension: nil)
}

/// The compiled terminfo database exported to child shells by Ghostty.
public static var terminfoDirectoryURL: URL? {
Bundle.module.url(forResource: "terminfo", withExtension: nil)
}

static func configureEnvironment() {
guard let path = directoryURL?.path else { return }
setenv("GHOSTTY_RESOURCES_DIR", path, 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// Created by Lakr233 on 2026/3/17.
//

import Foundation

public enum TerminalCursorStyle: String, Sendable, Hashable {
case block
case bar
Expand Down Expand Up @@ -51,7 +53,7 @@ public enum TerminalConfigCommand: Sendable, Hashable {
"font-family = \(value)"

case let .fontSize(value):
"font-size = \(value.formatted(.number.precision(.fractionLength(0 ... 2))))"
"font-size = \(configLiteral(value, maximumFractionDigits: 2))"

case let .fontThicken(enabled):
"font-thicken = \(enabled)"
Expand All @@ -72,7 +74,7 @@ public enum TerminalConfigCommand: Sendable, Hashable {
"cursor-text = \(value)"

case let .cursorOpacity(value):
"cursor-opacity = \(value.formatted(.number.precision(.fractionLength(0 ... 3))))"
"cursor-opacity = \(configLiteral(value, maximumFractionDigits: 3))"

case let .background(value):
"background = \(value)"
Expand All @@ -93,10 +95,10 @@ public enum TerminalConfigCommand: Sendable, Hashable {
"palette = \(index)=\(color)"

case let .minimumContrast(value):
"minimum-contrast = \(value.formatted(.number.precision(.fractionLength(0 ... 2))))"
"minimum-contrast = \(configLiteral(value, maximumFractionDigits: 2))"

case let .backgroundOpacity(value):
"background-opacity = \(value.formatted(.number.precision(.fractionLength(0 ... 3))))"
"background-opacity = \(configLiteral(value, maximumFractionDigits: 3))"

case let .backgroundBlur(value):
"background-blur = \(value)"
Expand All @@ -111,6 +113,20 @@ public enum TerminalConfigCommand: Sendable, Hashable {
"\(key) = \(value)"
}
}

// ghostty parses the value with std.fmt.parseFloat, which accepts only a
// `.` separator and ASCII digits; the default style follows the device
// region (`13,5` in de_DE, `١٤` in ar_EG) and the whole config is rejected.
private func configLiteral<Value: BinaryFloatingPoint>(
_ value: Value,
maximumFractionDigits: Int
) -> String {
value.formatted(
FloatingPointFormatStyle<Value>(locale: Locale(identifier: "en_US_POSIX"))
.precision(.fractionLength(0 ... maximumFractionDigits))
.grouping(.never)
)
}
}

public struct TerminalConfiguration: Sendable, Hashable {
Expand Down
Loading