Initial commit: macOS terminal app with vertical tabs using libghostty

Features:
- Vertical tabs sidebar with SwiftUI
- Terminal emulation via GhosttyKit.xcframework (libghostty)
- Keyboard shortcuts: Cmd+T/N, Ctrl+Shift+` (new tab), Cmd+W (close),
  Cmd+Shift+[/], Ctrl+Tab (navigation), Cmd+1-9 (jump to tab)
- Reads Ghostty config from ~/Library/Application Support/com.mitchellh.ghostty/config
- Metal-based rendering
This commit is contained in:
Lawrence Chen 2026-01-22 01:16:24 -08:00
commit c5bd543fe0
14 changed files with 2682 additions and 0 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Xcode
.build/
DerivedData/
*.xcuserstate
xcuserdata/
# macOS
.DS_Store
# Swift Package Manager
.swiftpm/
# GhosttyKit binary (rebuild from /tmp/ghostty with zig build)
GhosttyKit.xcframework/

47
CLAUDE.md Normal file
View file

@ -0,0 +1,47 @@
# GhosttyTabs
A macOS terminal app with vertical tabs, using libghostty (GhosttyKit.xcframework) for terminal emulation.
## User Preferences
- **Always use Release builds** when building and launching for testing
- Build libghostty with `-Doptimize=ReleaseFast` for performance
## Development
### Build and launch (Release)
```bash
cd /Users/lawrencechen/fun/cmux-terminal/GhosttyTabs
pkill -9 GhosttyTabs 2>/dev/null
xcodebuild -scheme GhosttyTabs -configuration Release build
open ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/GhosttyTabs.app
```
### Rebuild libghostty (optimized)
```bash
cd /tmp/ghostty
zig build -Demit-xcframework=true -Dxcframework-target=native -Doptimize=ReleaseFast
cp -R /tmp/ghostty/macos/GhosttyKit.xcframework /Users/lawrencechen/fun/cmux-terminal/GhosttyTabs/
```
### Project structure
- `Sources/` - Swift source files
- `GhosttyTabsApp.swift` - App entry point with keyboard shortcuts
- `ContentView.swift` - Main UI with vertical tabs sidebar
- `TabManager.swift` - Tab state management
- `GhosttyTerminalView.swift` - libghostty terminal integration
- `GhosttyConfig.swift` - Ghostty config parser
- `GhosttyKit.xcframework/` - libghostty static library
- `ghostty.h` - Ghostty C API header
- `GhosttyTabs-Bridging-Header.h` - Swift bridging header
### Keyboard Shortcuts
- `Cmd+T` / `Cmd+N` / `Ctrl+Shift+`` - New tab
- `Cmd+W` - Close tab
- `Cmd+Shift+]` / `Ctrl+Tab` - Next tab
- `Cmd+Shift+[` / `Ctrl+Shift+Tab` - Previous tab
- `Cmd+1-9` - Jump to tab by number
### Config
Reads user's Ghostty config from:
`~/Library/Application Support/com.mitchellh.ghostty/config`

View file

@ -0,0 +1 @@
#import "ghostty.h"

View file

@ -0,0 +1,317 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
A5001001 /* GhosttyTabsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* GhosttyTabsApp.swift */; };
A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; };
A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; };
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
A5001020 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
A5001000 /* GhosttyTabs.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GhosttyTabs.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5001011 /* GhosttyTabsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTabsApp.swift; sourceTree = "<group>"; };
A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
A5001018 /* GhosttyTabs-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "GhosttyTabs-Bridging-Header.h"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A5001030 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A5001006 /* GhosttyKit.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A5001040 = {
isa = PBXGroup;
children = (
A5001041 /* Sources */,
A5001016 /* GhosttyKit.xcframework */,
A5001017 /* ghostty.h */,
A5001018 /* GhosttyTabs-Bridging-Header.h */,
A5001042 /* Products */,
);
sourceTree = "<group>";
};
A5001041 /* Sources */ = {
isa = PBXGroup;
children = (
A5001011 /* GhosttyTabsApp.swift */,
A5001012 /* ContentView.swift */,
A5001013 /* TabManager.swift */,
A5001014 /* GhosttyConfig.swift */,
A5001015 /* GhosttyTerminalView.swift */,
);
path = Sources;
sourceTree = "<group>";
};
A5001042 /* Products */ = {
isa = PBXGroup;
children = (
A5001000 /* GhosttyTabs.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A5001050 /* GhosttyTabs */ = {
isa = PBXNativeTarget;
buildConfigurationList = A5001060 /* Build configuration list for PBXNativeTarget "GhosttyTabs" */;
buildPhases = (
A5001051 /* Sources */,
A5001030 /* Frameworks */,
A5001020 /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = GhosttyTabs;
productName = GhosttyTabs;
productReference = A5001000 /* GhosttyTabs.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A5001070 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
};
buildConfigurationList = A5001071 /* Build configuration list for PBXProject "GhosttyTabs" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A5001040;
productRefGroup = A5001042 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A5001050 /* GhosttyTabs */,
);
};
/* End PBXProject section */
/* Begin PBXSourcesBuildPhase section */
A5001051 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A5001001 /* GhosttyTabsApp.swift in Sources */,
A5001002 /* ContentView.swift in Sources */,
A5001003 /* TabManager.swift in Sources */,
A5001004 /* GhosttyConfig.swift in Sources */,
A5001005 /* GhosttyTerminalView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A5001080 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A5001081 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
A5001082 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMainStoryboardFile = "";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
Metal,
"-framework",
QuartzCore,
"-framework",
IOSurface,
"-framework",
UniformTypeIdentifiers,
"-framework",
Carbon,
);
PRODUCT_BUNDLE_IDENTIFIER = com.ghosttytabs.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "GhosttyTabs-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
A5001083 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMainStoryboardFile = "";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
Metal,
"-framework",
QuartzCore,
"-framework",
IOSurface,
"-framework",
UniformTypeIdentifiers,
"-framework",
Carbon,
);
PRODUCT_BUNDLE_IDENTIFIER = com.ghosttytabs.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "GhosttyTabs-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A5001060 /* Build configuration list for PBXNativeTarget "GhosttyTabs" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A5001082 /* Debug */,
A5001083 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A5001071 /* Build configuration list for PBXProject "GhosttyTabs" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A5001080 /* Debug */,
A5001081 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A5001070 /* Project object */;
}

23
Package.resolved Normal file
View file

@ -0,0 +1,23 @@
{
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.0"
}
},
{
"identity" : "swiftterm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
"state" : {
"revision" : "0b8d99bd19b694df44e1ccaa3891309719d34330",
"version" : "1.5.1"
}
}
],
"version" : 2
}

22
Package.swift Normal file
View file

@ -0,0 +1,22 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "GhosttyTabs",
platforms: [
.macOS(.v13)
],
products: [
.executable(name: "GhosttyTabs", targets: ["GhosttyTabs"])
],
dependencies: [
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0")
],
targets: [
.executableTarget(
name: "GhosttyTabs",
dependencies: ["SwiftTerm"],
path: "Sources"
)
]
)

120
Sources/ContentView.swift Normal file
View file

@ -0,0 +1,120 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var tabManager: TabManager
@State private var sidebarWidth: CGFloat = 200
var body: some View {
HStack(spacing: 0) {
// Vertical Tabs Sidebar
VerticalTabsSidebar(sidebarWidth: sidebarWidth)
.frame(width: sidebarWidth)
// Divider
Rectangle()
.fill(Color(nsColor: .separatorColor))
.frame(width: 1)
// Terminal Content
if let selectedId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) {
GhosttyTerminalView()
.id(tab.id)
} else {
Color(nsColor: .windowBackgroundColor)
}
}
.frame(minWidth: 800, minHeight: 600)
.background(Color(nsColor: .windowBackgroundColor))
}
}
struct VerticalTabsSidebar: View {
@EnvironmentObject var tabManager: TabManager
let sidebarWidth: CGFloat
var body: some View {
VStack(spacing: 0) {
// Header with title
HStack {
Text("Tabs")
.font(.headline)
.foregroundColor(.secondary)
Spacer()
Button(action: { tabManager.addTab() }) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
Divider()
// Tab List
ScrollView {
LazyVStack(spacing: 2) {
ForEach(tabManager.tabs) { tab in
TabItemView(tab: tab)
}
}
.padding(.vertical, 4)
}
Spacer()
}
.background(Color(nsColor: .controlBackgroundColor))
}
}
struct TabItemView: View {
@EnvironmentObject var tabManager: TabManager
@ObservedObject var tab: Tab
@State private var isHovering = false
var isSelected: Bool {
tabManager.selectedTabId == tab.id
}
var body: some View {
HStack(spacing: 8) {
Image(systemName: "terminal")
.font(.system(size: 12))
.foregroundColor(isSelected ? .white : .secondary)
Text(tab.title)
.font(.system(size: 12))
.foregroundColor(isSelected ? .white : .primary)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
if isHovering || isSelected {
Button(action: { tabManager.closeTab(tab) }) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .medium))
.foregroundColor(isSelected ? .white.opacity(0.7) : .secondary)
}
.buttonStyle(.plain)
.opacity(tabManager.tabs.count > 1 ? 1 : 0)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isSelected ? Color.accentColor : (isHovering ? Color(nsColor: .controlBackgroundColor).opacity(0.5) : Color.clear))
)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.onTapGesture {
tabManager.selectTab(tab)
}
.onHover { hovering in
isHovering = hovering
}
}
}

143
Sources/GhosttyConfig.swift Normal file
View file

@ -0,0 +1,143 @@
import Foundation
import AppKit
struct GhosttyConfig {
var fontFamily: String = "Menlo"
var fontSize: CGFloat = 12
var theme: String?
var workingDirectory: String?
var scrollbackLimit: Int = 10000
// Colors (from theme or config)
var backgroundColor: NSColor = NSColor(hex: "#272822")!
var foregroundColor: NSColor = NSColor(hex: "#fdfff1")!
var cursorColor: NSColor = NSColor(hex: "#c0c1b5")!
var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")!
var selectionBackground: NSColor = NSColor(hex: "#57584f")!
var selectionForeground: NSColor = NSColor(hex: "#fdfff1")!
// Palette colors (0-15)
var palette: [Int: NSColor] = [:]
static func load() -> GhosttyConfig {
var config = GhosttyConfig()
// Load user config
let configPath = NSString(string: "~/Library/Application Support/com.mitchellh.ghostty/config").expandingTildeInPath
if let contents = try? String(contentsOfFile: configPath, encoding: .utf8) {
config.parse(contents)
}
// Load theme if specified
if let themeName = config.theme {
config.loadTheme(themeName)
}
return config
}
mutating func parse(_ contents: String) {
let lines = contents.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") {
continue
}
let parts = trimmed.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
let key = parts[0].trimmingCharacters(in: .whitespaces)
let value = parts[1].trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
switch key {
case "font-family":
fontFamily = value
case "font-size":
if let size = Double(value) {
fontSize = CGFloat(size)
}
case "theme":
theme = value
case "working-directory":
workingDirectory = value
case "scrollback-limit":
if let limit = Int(value) {
scrollbackLimit = limit
}
case "background":
if let color = NSColor(hex: value) {
backgroundColor = color
}
case "foreground":
if let color = NSColor(hex: value) {
foregroundColor = color
}
case "cursor-color":
if let color = NSColor(hex: value) {
cursorColor = color
}
case "cursor-text":
if let color = NSColor(hex: value) {
cursorTextColor = color
}
case "selection-background":
if let color = NSColor(hex: value) {
selectionBackground = color
}
case "selection-foreground":
if let color = NSColor(hex: value) {
selectionForeground = color
}
case "palette":
// Parse palette entries like "0=#272822"
let paletteParts = value.split(separator: "=", maxSplits: 1)
if paletteParts.count == 2,
let index = Int(paletteParts[0]),
let color = NSColor(hex: String(paletteParts[1])) {
palette[index] = color
}
default:
break
}
}
}
}
mutating func loadTheme(_ name: String) {
// Try to load from Ghostty app resources
let themePaths = [
"/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(name)",
NSString(string: "~/.config/ghostty/themes/\(name)").expandingTildeInPath
]
for path in themePaths {
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
parse(contents)
return
}
}
}
}
extension NSColor {
convenience init?(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
return nil
}
let r, g, b: CGFloat
if hexSanitized.count == 6 {
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
b = CGFloat(rgb & 0x0000FF) / 255.0
} else {
return nil
}
self.init(red: r, green: g, blue: b, alpha: 1.0)
}
}

View file

@ -0,0 +1,74 @@
import SwiftUI
@main
struct GhosttyTabsApp: App {
@StateObject private var tabManager = TabManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(tabManager)
}
.windowStyle(.hiddenTitleBar)
.commands {
// New tab commands
CommandGroup(replacing: .newItem) {
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("t", modifiers: .command)
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("n", modifiers: .command)
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("`", modifiers: [.control, .shift])
}
// Close tab
CommandGroup(after: .newItem) {
Button("Close Tab") {
tabManager.closeCurrentTab()
}
.keyboardShortcut("w", modifiers: .command)
}
// Tab navigation
CommandGroup(after: .toolbar) {
Button("Next Tab") {
tabManager.selectNextTab()
}
.keyboardShortcut("]", modifiers: [.command, .shift])
Button("Previous Tab") {
tabManager.selectPreviousTab()
}
.keyboardShortcut("[", modifiers: [.command, .shift])
Button("Next Tab") {
tabManager.selectNextTab()
}
.keyboardShortcut(.tab, modifiers: .control)
Button("Previous Tab") {
tabManager.selectPreviousTab()
}
.keyboardShortcut(.tab, modifiers: [.control, .shift])
Divider()
// Cmd+1 through Cmd+9 for tab selection
ForEach(1...9, id: \.self) { number in
Button("Tab \(number)") {
tabManager.selectTab(at: number - 1)
}
.keyboardShortcut(KeyEquivalent(Character("\(number)")), modifiers: .command)
}
}
}
}
}

View file

@ -0,0 +1,383 @@
import SwiftUI
import AppKit
import Metal
import QuartzCore
// Minimal Ghostty wrapper for terminal rendering
// This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation
// MARK: - Ghostty App Singleton
class GhosttyApp {
static let shared = GhosttyApp()
private(set) var app: ghostty_app_t?
private(set) var config: ghostty_config_t?
private init() {
initializeGhostty()
}
private func initializeGhostty() {
// Initialize Ghostty library first
let result = ghostty_init(0, nil)
if result != GHOSTTY_SUCCESS {
print("Failed to initialize ghostty: \(result)")
return
}
// Load config
config = ghostty_config_new()
guard let config = config else {
print("Failed to create ghostty config")
return
}
// Load default config
ghostty_config_load_default_files(config)
ghostty_config_finalize(config)
// Create runtime config with callbacks
var runtimeConfig = ghostty_runtime_config_s()
runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
runtimeConfig.supports_selection_clipboard = true
runtimeConfig.wakeup_cb = { userdata in
DispatchQueue.main.async {
// Wakeup - trigger redraw if needed
}
}
runtimeConfig.action_cb = { app, target, action in
// Handle actions
return false
}
runtimeConfig.read_clipboard_cb = { userdata, location, state in
// Read clipboard
}
runtimeConfig.write_clipboard_cb = { userdata, location, content, len, confirm in
// Write clipboard
if let content = content {
let data = Data(bytes: content, count: Int(len))
if let string = String(data: data, encoding: .utf8) {
DispatchQueue.main.async {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
}
}
}
}
runtimeConfig.close_surface_cb = { userdata, processAlive in
// Surface closed
}
// Create app
app = ghostty_app_new(&runtimeConfig, config)
if app == nil {
print("Failed to create ghostty app")
}
}
func tick() {
guard let app = app else { return }
ghostty_app_tick(app)
}
}
// MARK: - Ghostty Surface View
class GhosttyNSView: NSView {
private var surface: ghostty_surface_t?
private var displayLink: CVDisplayLink?
private var metalDevice: MTLDevice?
private var surfaceCreated = false
override func makeBackingLayer() -> CALayer {
let metalLayer = CAMetalLayer()
metalLayer.device = MTLCreateSystemDefaultDevice()
metalLayer.pixelFormat = .bgra8Unorm
metalLayer.framebufferOnly = true
metalLayer.isOpaque = true
metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
self.metalDevice = metalLayer.device
return metalLayer
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
wantsLayer = true
layerContentsRedrawPolicy = .duringViewResize
}
private func createSurfaceIfNeeded() {
// Only create once and when we have a valid size
guard !surfaceCreated else { return }
guard bounds.width > 0 && bounds.height > 0 else { return }
guard window != nil else { return }
surfaceCreated = true
createSurface()
}
private func createSurface() {
guard let app = GhosttyApp.shared.app else {
print("Ghostty app not initialized")
return
}
let scale = window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
// Update Metal layer with initial size
if let metalLayer = layer as? CAMetalLayer {
metalLayer.contentsScale = scale
metalLayer.drawableSize = CGSize(
width: bounds.width * scale,
height: bounds.height * scale
)
}
var surfaceConfig = ghostty_surface_config_new()
surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS
// Pass this view to ghostty
surfaceConfig.platform.macos.nsview = Unmanaged.passUnretained(self).toOpaque()
// Set scale factor
surfaceConfig.scale_factor = scale
surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW
// Create the surface
surface = ghostty_surface_new(app, &surfaceConfig)
if surface == nil {
print("Failed to create ghostty surface")
return
}
// Set initial size immediately after creation
ghostty_surface_set_size(
surface,
UInt32(bounds.width * scale),
UInt32(bounds.height * scale)
)
// Setup display link for rendering
setupDisplayLink()
}
private func setupDisplayLink() {
var link: CVDisplayLink?
CVDisplayLinkCreateWithActiveCGDisplays(&link)
guard let displayLink = link else { return }
self.displayLink = displayLink
let callback: CVDisplayLinkOutputCallback = { displayLink, inNow, inOutputTime, flagsIn, flagsOut, displayLinkContext -> CVReturn in
DispatchQueue.main.async {
GhosttyApp.shared.tick()
}
return kCVReturnSuccess
}
CVDisplayLinkSetOutputCallback(displayLink, callback, nil)
CVDisplayLinkStart(displayLink)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window != nil {
createSurfaceIfNeeded()
updateSurfaceSize()
}
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
createSurfaceIfNeeded()
updateSurfaceSize()
}
override func layout() {
super.layout()
createSurfaceIfNeeded()
}
private func updateSurfaceSize() {
guard let surface = surface else { return }
let scale = window?.screen?.backingScaleFactor ?? 2.0
// Update Metal layer
if let metalLayer = layer as? CAMetalLayer {
metalLayer.contentsScale = scale
metalLayer.drawableSize = CGSize(
width: bounds.width * scale,
height: bounds.height * scale
)
}
ghostty_surface_set_size(
surface,
UInt32(bounds.width * scale),
UInt32(bounds.height * scale)
)
}
// MARK: - Input Handling
override var acceptsFirstResponder: Bool { true }
private func ghosttyCharacters(from event: NSEvent) -> String? {
guard let chars = event.characters, !chars.isEmpty else { return nil }
for scalar in chars.unicodeScalars where scalar.value < 0x20 {
return nil
}
return chars
}
override func keyDown(with event: NSEvent) {
guard let surface = surface else {
super.keyDown(with: event)
return
}
interpretKeyEvents([event])
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = UInt32(event.keyCode)
keyEvent.mods = modsFromEvent(event)
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.composing = false
if let text = ghosttyCharacters(from: event) {
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
} else {
keyEvent.text = nil
_ = ghostty_surface_key(surface, keyEvent)
}
}
override func keyUp(with event: NSEvent) {
guard let surface = surface else {
super.keyUp(with: event)
return
}
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_RELEASE
keyEvent.keycode = UInt32(event.keyCode)
keyEvent.mods = modsFromEvent(event)
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = nil
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
}
override func flagsChanged(with event: NSEvent) {
guard let surface = surface else {
super.flagsChanged(with: event)
return
}
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = UInt32(event.keyCode)
keyEvent.mods = modsFromEvent(event)
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = nil
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
}
private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e {
var mods = GHOSTTY_MODS_NONE.rawValue
if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue }
if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue }
return ghostty_input_mods_e(rawValue: mods)
}
// MARK: - Mouse Handling
override func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
}
override func mouseUp(with event: NSEvent) {
guard let surface = surface else { return }
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
}
override func mouseMoved(with event: NSEvent) {
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
}
override func mouseDragged(with event: NSEvent) {
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
}
override func scrollWheel(with event: NSEvent) {
guard let surface = surface else { return }
var mods: Int32 = 0
if event.modifierFlags.contains(.shift) { mods |= Int32(GHOSTTY_MODS_SHIFT.rawValue) }
if event.modifierFlags.contains(.control) { mods |= Int32(GHOSTTY_MODS_CTRL.rawValue) }
if event.modifierFlags.contains(.option) { mods |= Int32(GHOSTTY_MODS_ALT.rawValue) }
if event.modifierFlags.contains(.command) { mods |= Int32(GHOSTTY_MODS_SUPER.rawValue) }
ghostty_surface_mouse_scroll(
surface,
event.scrollingDeltaX,
event.scrollingDeltaY,
ghostty_input_scroll_mods_t(mods)
)
}
deinit {
if let displayLink = displayLink {
CVDisplayLinkStop(displayLink)
}
if let surface = surface {
ghostty_surface_free(surface)
}
}
}
// MARK: - SwiftUI Wrapper
struct GhosttyTerminalView: NSViewRepresentable {
func makeNSView(context: Context) -> GhosttyNSView {
let view = GhosttyNSView(frame: .zero)
// Focus after view is in window
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
view.window?.makeFirstResponder(view)
}
return view
}
func updateNSView(_ nsView: GhosttyNSView, context: Context) {
// Focus on tab switch
DispatchQueue.main.async {
nsView.window?.makeFirstResponder(nsView)
}
}
}

73
Sources/TabManager.swift Normal file
View file

@ -0,0 +1,73 @@
import SwiftUI
import Foundation
class Tab: Identifiable, ObservableObject {
let id = UUID()
@Published var title: String
@Published var currentDirectory: String
init(title: String = "Terminal") {
self.title = title
self.currentDirectory = FileManager.default.homeDirectoryForCurrentUser.path
}
}
class TabManager: ObservableObject {
@Published var tabs: [Tab] = []
@Published var selectedTabId: UUID?
init() {
addTab()
}
func addTab() {
let newTab = Tab(title: "Terminal \(tabs.count + 1)")
tabs.append(newTab)
selectedTabId = newTab.id
}
func closeTab(_ tab: Tab) {
guard tabs.count > 1 else { return }
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
tabs.remove(at: index)
if selectedTabId == tab.id {
if index > 0 {
selectedTabId = tabs[index - 1].id
} else {
selectedTabId = tabs.first?.id
}
}
}
}
func closeCurrentTab() {
guard let selectedId = selectedTabId,
let tab = tabs.first(where: { $0.id == selectedId }) else { return }
closeTab(tab)
}
func selectTab(_ tab: Tab) {
selectedTabId = tab.id
}
func selectNextTab() {
guard let currentId = selectedTabId,
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
let nextIndex = (currentIndex + 1) % tabs.count
selectedTabId = tabs[nextIndex].id
}
func selectPreviousTab() {
guard let currentId = selectedTabId,
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
selectedTabId = tabs[prevIndex].id
}
func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }
selectedTabId = tabs[index].id
}
}

287
Sources/TerminalView.swift Normal file
View file

@ -0,0 +1,287 @@
import SwiftUI
import SwiftTerm
import AppKit
// Helper to create SwiftTerm Color from hex
extension SwiftTerm.Color {
convenience init(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
Scanner(string: hexSanitized).scanHexInt64(&rgb)
let r = UInt16((rgb & 0xFF0000) >> 16)
let g = UInt16((rgb & 0x00FF00) >> 8)
let b = UInt16(rgb & 0x0000FF)
// Convert 8-bit to 16-bit
self.init(red: r * 257, green: g * 257, blue: b * 257)
}
}
struct TerminalContainerView: View {
@ObservedObject var tab: Tab
let config: GhosttyConfig
init(tab: Tab, config: GhosttyConfig = GhosttyConfig.load()) {
self.tab = tab
self.config = config
}
var body: some View {
SwiftTermView(tab: tab, config: config)
.background(Color(config.backgroundColor))
}
}
// Custom wrapper to handle first responder and layout
class FocusableTerminalView: NSView {
var terminalView: LocalProcessTerminalView?
private var scroller: NSScroller?
private var fadeTimer: Timer?
private var scrollMonitor: Any?
private var lastScrollerValue: Double = 0
override var acceptsFirstResponder: Bool { true }
override func becomeFirstResponder() -> Bool {
if let tv = terminalView {
DispatchQueue.main.async {
self.window?.makeFirstResponder(tv)
}
}
return true
}
override func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(terminalView)
super.mouseDown(with: event)
}
override func layout() {
super.layout()
if let tv = terminalView, bounds.size.width > 0, bounds.size.height > 0 {
tv.setFrameSize(bounds.size)
setupScrollerTracking(in: tv)
}
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window != nil, let tv = terminalView, bounds.size.width > 0 {
tv.setFrameSize(bounds.size)
setupScrollerTracking(in: tv)
setupScrollMonitor()
}
}
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
if newWindow == nil, let monitor = scrollMonitor {
NSEvent.removeMonitor(monitor)
scrollMonitor = nil
}
}
private func setupScrollerTracking(in view: NSView) {
if scroller == nil {
for subview in view.subviews {
if let s = subview as? NSScroller {
scroller = s
s.alphaValue = 0 // Start hidden
lastScrollerValue = s.doubleValue
break
}
}
}
}
private func setupScrollMonitor() {
guard scrollMonitor == nil else { return }
// Monitor scroll wheel events
scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
if let self = self,
let window = self.window,
event.window == window {
self.showScrollerTemporarily()
}
return event
}
}
func showScrollerTemporarily() {
guard let scroller = scroller else { return }
// Show scroller
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.15
scroller.animator().alphaValue = 1
}
// Cancel existing timer
fadeTimer?.invalidate()
// Fade out after 1.5 seconds of no scrolling
fadeTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
self?.scroller?.animator().alphaValue = 0
}
}
}
}
struct SwiftTermView: NSViewRepresentable {
@ObservedObject var tab: Tab
let config: GhosttyConfig
func makeNSView(context: Context) -> FocusableTerminalView {
let containerView = FocusableTerminalView()
containerView.wantsLayer = true
let terminalView = LocalProcessTerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 600))
// Use autoresizingMask instead of Auto Layout for SwiftTerm compatibility
terminalView.autoresizingMask = [.width, .height]
// Apply Ghostty config colors
terminalView.nativeForegroundColor = config.foregroundColor
terminalView.nativeBackgroundColor = config.backgroundColor
// Set cursor color to match Ghostty
terminalView.caretColor = config.cursorColor
terminalView.caretTextColor = config.cursorTextColor
// Set selection colors
terminalView.selectedTextBackgroundColor = config.selectionBackground
// Apply ANSI palette colors
applyPalette(to: terminalView, config: config)
// Configure font from config
if let font = NSFont(name: config.fontFamily, size: config.fontSize) {
terminalView.font = font
} else {
terminalView.font = NSFont.monospacedSystemFont(ofSize: config.fontSize, weight: .regular)
}
// Set terminal delegate (only processDelegate, not terminalDelegate which breaks input)
terminalView.processDelegate = context.coordinator
context.coordinator.terminalView = terminalView
context.coordinator.containerView = containerView
containerView.addSubview(terminalView)
containerView.terminalView = terminalView
// Get shell path
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
// Determine working directory
let workingDir = config.workingDirectory ?? FileManager.default.homeDirectoryForCurrentUser.path
// Build environment with working directory
var env = ProcessInfo.processInfo.environment
env["PWD"] = workingDir
// Start the shell process
terminalView.startProcess(
executable: shell,
args: [],
environment: env.map { "\($0.key)=\($0.value)" },
execName: "-" + (shell as NSString).lastPathComponent
)
// Change to working directory
terminalView.feed(text: "cd \"\(workingDir)\" && clear\n")
// Make first responder after a delay to ensure window is ready
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
containerView.window?.makeFirstResponder(terminalView)
}
return containerView
}
func updateNSView(_ nsView: FocusableTerminalView, context: Context) {
// When this view becomes visible (tab switch), make it first responder
DispatchQueue.main.async {
if let terminalView = nsView.terminalView {
nsView.window?.makeFirstResponder(terminalView)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(tab: tab)
}
private func applyPalette(to terminalView: LocalProcessTerminalView, config: GhosttyConfig) {
// SwiftTerm uses installColors to set the ANSI color palette
// Build the color array (16 ANSI colors)
// Default Monokai Classic palette hex values
let defaultPaletteHex: [String] = [
"#272822", // 0 - black
"#f92672", // 1 - red
"#a6e22e", // 2 - green
"#e6db74", // 3 - yellow
"#fd971f", // 4 - blue (orange in Monokai)
"#ae81ff", // 5 - magenta
"#66d9ef", // 6 - cyan
"#fdfff1", // 7 - white
"#6e7066", // 8 - bright black
"#f92672", // 9 - bright red
"#a6e22e", // 10 - bright green
"#e6db74", // 11 - bright yellow
"#fd971f", // 12 - bright blue
"#ae81ff", // 13 - bright magenta
"#66d9ef", // 14 - bright cyan
"#fdfff1", // 15 - bright white
]
var colors: [SwiftTerm.Color] = []
for i in 0..<16 {
colors.append(SwiftTerm.Color(hex: defaultPaletteHex[i]))
}
// Install the ANSI colors
terminalView.installColors(colors)
}
class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
var tab: Tab
weak var terminalView: LocalProcessTerminalView?
weak var containerView: FocusableTerminalView?
init(tab: Tab) {
self.tab = tab
}
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {
// Handle size change
}
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {
DispatchQueue.main.async {
if !title.isEmpty {
self.tab.title = title
}
}
}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
if let dir = directory {
DispatchQueue.main.async {
self.tab.currentDirectory = dir
}
}
}
func processTerminated(source: TerminalView, exitCode: Int32?) {
// Could close tab or show message
}
}
}

1160
ghostty.h Normal file

File diff suppressed because it is too large Load diff

18
scripts/rebuild.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/bash
# Rebuild and restart GhosttyTabs app
set -e
cd "$(dirname "$0")/.."
# Kill existing app if running
pkill -9 -f "GhosttyTabs" 2>/dev/null || true
# Build
swift build
# Copy to app bundle
cp .build/debug/GhosttyTabs .build/debug/GhosttyTabs.app/Contents/MacOS/
# Open the app
open .build/debug/GhosttyTabs.app