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
24 changes: 24 additions & 0 deletions Macterm/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
/// so the nonisolated deinit can read it — the object is being destroyed, so
/// there is no concurrent access. Tokens are `NSObjectProtocol` (what
/// `addObserver(forName:…)` returns).
nonisolated(unsafe) private var observerTokens: [(center: NotificationCenter, token: NSObjectProtocol)] = []

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / Unit

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / Unit

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / End-to-end

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / End-to-end

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / Window states

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

/// Injectable for tests (`PollCadence.Context` inputs). `NSApp` is nil
/// while the SwiftUI `App` struct (and thus AppState) is constructed —
Expand Down Expand Up @@ -1390,6 +1390,30 @@
workspaces[projectID]?.activeTab?.focusPane(paneID)
}

/// Begin the sidebar rename flow for the tab containing `paneID` — the
/// ghostty `prompt_surface_title` keybind's Macterm mapping (titles live
/// on tabs here, not surfaces). No-op for panes outside any workspace
/// (the quick terminal has no tab UI to rename).
func renameTab(containing paneID: UUID, projectID: UUID) {
guard let tab = workspaces[projectID]?.tabs.first(where: { $0.splitRoot.contains(paneID: paneID) })
else { return }
sidebarVisible = true
let tabID = tab.id
// Defer a tick so the sidebar row's TextField exists before it's asked
// to begin editing — same reason as the Rename Tab command.
DispatchQueue.main.async { self.renamingTabID = tabID }
}

/// Set (or, with nil, clear) the custom title of the tab containing
/// `paneID` — the ghostty `set_tab_title` keybind's Macterm mapping,
/// writing the same field the sidebar rename edits.
func setTabTitle(containing paneID: UUID, projectID: UUID, title: String?) {
guard let tab = workspaces[projectID]?.tabs.first(where: { $0.splitRoot.contains(paneID: paneID) })
else { return }
tab.customTitle = title
saveWorkspaces()
}

func navigateToPane(_ paneID: UUID, projectID: UUID) {
guard workspaces[projectID] != nil else {
NSApp.activate()
Expand Down
60 changes: 60 additions & 0 deletions Macterm/Ghostty/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,66 @@ final class GhosttyApp {
return String(bytes: UnsafeRawBufferPointer(start: ptr, count: Int(str.len)), encoding: .utf8)
}

private func configBool(_ key: String, default defaultValue: Bool) -> Bool {
guard let config else { return defaultValue }
var value = defaultValue
guard ghostty_config_get(config, &value, key, UInt(key.utf8.count)) else { return defaultValue }
return value
}

// MARK: - Bell & secure input config (read by GhosttyCallbacks)

/// The user's `bell-features` set. Same bit layout as Ghostty.app's
/// `BellFeatures`; `title` and `border` exist in the config but Macterm
/// implements only the app-level features (see `GhosttyCallbacks`'s
/// `RING_BELL` case).
struct BellFeatures: OptionSet {
let rawValue: CUnsignedInt
static let system = BellFeatures(rawValue: 1 << 0)
static let audio = BellFeatures(rawValue: 1 << 1)
static let attention = BellFeatures(rawValue: 1 << 2)
static let title = BellFeatures(rawValue: 1 << 3)
static let border = BellFeatures(rawValue: 1 << 4)
}

var bellFeatures: BellFeatures {
guard let config else { return [] }
var raw: CUnsignedInt = 0
let key = "bell-features"
guard ghostty_config_get(config, &raw, key, UInt(key.utf8.count)) else { return [] }
return BellFeatures(rawValue: raw)
}

/// Absolute path of the user's `bell-audio-path`, or nil when unset.
var bellAudioPath: String? {
guard let config else { return nil }
var value = ghostty_config_path_s()
let key = "bell-audio-path"
guard ghostty_config_get(config, &value, key, UInt(key.utf8.count)), let ptr = value.path else { return nil }
let path = String(cString: ptr)
return path.isEmpty ? nil : path
}

var bellAudioVolume: Float {
guard let config else { return 0.5 }
var value = 0.5
let key = "bell-audio-volume"
_ = ghostty_config_get(config, &value, key, UInt(key.utf8.count))
return Float(value)
}

/// `macos-auto-secure-input`: gate for enabling secure keyboard input
/// automatically while a surface reports a password prompt.
var autoSecureInput: Bool {
configBool("macos-auto-secure-input", default: true)
}

/// `macos-secure-input-indication`: whether to show the per-pane lock
/// badge while secure input is active.
var secureInputIndication: Bool {
configBool("macos-secure-input-indication", default: true)
}

private func loadConfig() -> (ghostty_config_t?, ReloadResult) {
var result = ReloadResult(diagnostics: [])
guard let cfg = ghostty_config_new() else { return (nil, result) }
Expand Down
223 changes: 223 additions & 0 deletions Macterm/Ghostty/GhosttyCallbacks.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import AppKit
import GhosttyKit
import os
import UniformTypeIdentifiers

private let logger = Logger(subsystem: appBundleID, category: "GhosttyCallbacks")

/// Routes libghostty runtime callbacks to the appropriate terminal views.
final class GhosttyCallbacks: @unchecked Sendable {
Expand Down Expand Up @@ -112,11 +116,230 @@ final class GhosttyCallbacks: @unchecked Sendable {
let snapshot = GhosttyApp.readColors(from: cfg)
DispatchQueue.main.async { GhosttyApp.shared.adoptResolvedColors(snapshot) }
return true
case GHOSTTY_ACTION_OPEN_URL:
// Keybind actions that end in opening something — link clicks,
// `write_scrollback_file:open` and friends. Left unhandled,
// libghostty still opens the file itself (a spawned `open -t`),
// but logs it as an apprt failure; handling it here matches
// Ghostty.app. The url bytes are NOT null-terminated (`len`
// bounds them) and the pointer is owned by libghostty for this
// call only, so copy synchronously.
let payload = action.action.open_url
guard let ptr = payload.url, payload.len > 0 else { return true }
let urlString = String(
decoding: UnsafeRawBufferPointer(start: ptr, count: Int(payload.len)),
as: UTF8.self
)
let kind = payload.kind
DispatchQueue.main.async { Self.openURL(urlString, kind: kind) }
return true
case GHOSTTY_ACTION_MOUSE_SHAPE:
// The pointer shape for the current mouse position — I-beam over
// text, pointing hand over an OSC 8 link, resize arrows for TUI
// drags. Applied as the scroll view's documentCursor.
guard let view = surfaceView(from: target) else { return true }
let shape = action.action.mouse_shape
DispatchQueue.main.async { view.surfaceDidChangeMouseShape(shape) }
return true
case GHOSTTY_ACTION_MOUSE_VISIBILITY:
// mouse-hide-while-typing. Hidden-until-move matches Ghostty.app:
// any physical mouse movement reveals the cursor again, so we
// never need to balance hide/unhide pairs.
guard surfaceView(from: target) != nil else { return true }
let hidden = action.action.mouse_visibility == GHOSTTY_MOUSE_HIDDEN
DispatchQueue.main.async { NSCursor.setHiddenUntilMouseMoves(hidden) }
return true
case GHOSTTY_ACTION_MOUSE_OVER_LINK:
// The URL under the pointer, for the pane's hover banner. Zero
// length means the pointer left the link. Bytes are len-bounded
// (not null-terminated) and owned by libghostty for this call.
guard let view = surfaceView(from: target) else { return true }
let link = action.action.mouse_over_link
var url: String?
if let ptr = link.url, link.len > 0 {
url = String(
decoding: UnsafeRawBufferPointer(start: ptr, count: Int(link.len)),
as: UTF8.self
)
}
DispatchQueue.main.async { view.surfaceDidHoverLink(url) }
return true
case GHOSTTY_ACTION_SECURE_INPUT:
// App target: the user's `toggle_secure_input` keybind. Surface
// target: libghostty detected a password prompt — honored only
// when `macos-auto-secure-input` allows (checked on the main
// queue: config access is main-actor).
let mode = action.action.secure_input
switch target.tag {
case GHOSTTY_TARGET_APP:
DispatchQueue.main.async {
switch mode {
case GHOSTTY_SECURE_INPUT_ON: SecureInput.shared.setGlobal(true)
case GHOSTTY_SECURE_INPUT_OFF: SecureInput.shared.setGlobal(false)
default: SecureInput.shared.toggleGlobal()
}
}
return true
case GHOSTTY_TARGET_SURFACE:
guard let view = surfaceView(from: target) else { return true }
DispatchQueue.main.async {
guard GhosttyApp.shared.autoSecureInput else { return }
switch mode {
case GHOSTTY_SECURE_INPUT_ON: view.passwordInput = true
case GHOSTTY_SECURE_INPUT_OFF: view.passwordInput = false
default: view.passwordInput.toggle()
}
}
return true
default:
return false
}
case GHOSTTY_ACTION_RING_BELL:
// BEL. The `title`/`border` bell-features are per-tab UI Macterm
// doesn't implement; the app-level features (beep, custom sound,
// dock attention) are handled here for any surface.
guard target.tag == GHOSTTY_TARGET_SURFACE else { return false }
DispatchQueue.main.async { Self.ringBell() }
return true
case GHOSTTY_ACTION_OPEN_CONFIG:
// The `open_config` keybind. Macterm's source of truth is the
// user's own ghostty config file, so open that — created empty
// first if it doesn't exist yet, matching Ghostty.app.
DispatchQueue.main.async { Self.openUserConfig() }
return true
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
// Same guard as the Check for Update command: no-op while a
// Sparkle check is already in flight.
DispatchQueue.main.async {
guard Updater.shared.canCheckForUpdates else { return }
Updater.shared.checkForUpdates()
}
return true
case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD:
// The pane owns title state (program title / process name), so it
// supplies the string via `titleProvider`.
guard let view = surfaceView(from: target) else { return true }
DispatchQueue.main.async {
guard let title = view.titleProvider?(), !title.isEmpty else { return }
Self.setPasteboardString(title)
}
return true
case GHOSTTY_ACTION_PROMPT_TITLE:
// `prompt_surface_title`. Macterm titles live on tabs (a surface
// has no separate title UI), so both the surface and tab variants
// route to the containing tab's rename flow.
guard let view = surfaceView(from: target) else { return true }
DispatchQueue.main.async { view.onPromptTitle?() }
return true
case GHOSTTY_ACTION_SET_TAB_TITLE:
// The `set_tab_title` keybind: an explicit user-driven title, so
// it maps to the tab's customTitle (what rename-tab sets). Empty
// restores the automatic title, same contract as Ghostty.app.
guard let view = surfaceView(from: target) else { return true }
let title = action.action.set_tab_title.title.flatMap { String(cString: $0) } ?? ""
DispatchQueue.main.async { view.onSetTabTitle?(title.isEmpty ? nil : title) }
return true
case GHOSTTY_ACTION_RENDERER_HEALTH:
// No recovery UI (Ghostty.app shows a banner); surfacing the
// transition in the log is what makes a black pane diagnosable.
let health = action.action.renderer_health
if health == GHOSTTY_RENDERER_HEALTH_UNHEALTHY {
logger.error("renderer reported unhealthy for a surface")
} else {
logger.info("renderer recovered for a surface")
}
return true
default:
// Deliberately unhandled: window/tab/split management actions
// (NEW_TAB, NEW_SPLIT, GOTO_*, TOGGLE_FULLSCREEN, QUIT, …) —
// those concepts are Macterm-owned via AppCommand/hotkeys, not
// ghostty keybinds; GTK/iOS-only actions (SHOW_GTK_INSPECTOR,
// SHOW_ON_SCREEN_KEYBOARD); the imgui INSPECTOR; sizing hints
// (CELL_SIZE, SIZE_LIMIT, INITIAL_SIZE, RESET_WINDOW_SIZE) that
// don't map onto a single shared window of splits; UNDO/REDO
// (no app-level undo stack); COLOR_CHANGE (OSC 4/10/11 recolors
// the grid core-side; Macterm's chrome follows the resolved
// theme via CONFIG_CHANGE, not per-surface dynamic colors);
// SELECTION_CHANGED (accessibility text APIs not implemented);
// and KEY_SEQUENCE/KEY_TABLE progress UI (sequences themselves
// still work core-side). SHOW_CHILD_EXITED stays unhandled on
// purpose: returning false makes the core render its own
// abnormal-exit overlay, which is the error UI Macterm relies on.
return false
}
}

/// Ring the terminal bell per the user's `bell-features`: the system
/// beep, an optional custom sound, and a dock-bounce attention request
/// (which macOS shows only while the app is inactive).
@MainActor
private static func ringBell() {
let features = GhosttyApp.shared.bellFeatures
if features.contains(.system) {
NSSound.beep()
}
if features.contains(.audio),
let path = GhosttyApp.shared.bellAudioPath,
let sound = NSSound(contentsOfFile: path, byReference: false)
{
sound.volume = GhosttyApp.shared.bellAudioVolume
sound.play()
}
if features.contains(.attention) {
NSApp.requestUserAttention(.informationalRequest)
}
}

/// Open the user's ghostty config in their text editor, creating an empty
/// file (and its directory) first so a fresh setup gets an editable file
/// rather than a silent no-op.
@MainActor
private static func openUserConfig() {
let path = Preferences.shared.expandedUserGhosttyConfigPath
guard !path.isEmpty else { return }
if !FileManager.default.fileExists(atPath: path) {
let url = URL(fileURLWithPath: path)
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
FileManager.default.createFile(atPath: path, contents: nil)
}
openURL(path, kind: GHOSTTY_ACTION_OPEN_URL_KIND_TEXT)
}

// MARK: - Open URL (GHOSTTY_ACTION_OPEN_URL)

/// The URL a `GHOSTTY_ACTION_OPEN_URL` payload string denotes. A string
/// without a scheme is a file path — `URL(string:)` would happily build a
/// schemeless URL from it that no application can open (ghostty#8763) —
/// so those resolve via the file-URL initializer, with `~` expanded.
static func resolvedOpenTarget(_ string: String) -> URL {
if let candidate = URL(string: string), candidate.scheme != nil {
return candidate
}
return URL(fileURLWithPath: (string as NSString).standardizingPath)
}

private static func openURL(_ string: String, kind: ghostty_action_open_url_kind_e) {
let url = resolvedOpenTarget(string)
// `.text` asks for the payload to be *viewed as text* (scrollback
// dumps land here): prefer the default app for the file's extension,
// then the system plain-text editor — same order as Ghostty.app.
// `.html`/`.unknown` just go to the default handler.
if kind == GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, let editor = defaultTextEditor(for: url) {
NSWorkspace.shared.open([url], withApplicationAt: editor, configuration: NSWorkspace.OpenConfiguration())
return
}
NSWorkspace.shared.open(url)
}

private static func defaultTextEditor(for url: URL) -> URL? {
let byExtension = UTType(filenameExtension: url.pathExtension)
.flatMap { NSWorkspace.shared.urlForApplication(toOpen: $0) }
return byExtension ?? NSWorkspace.shared.urlForApplication(toOpen: .plainText)
}

func readClipboard(ud: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) -> Bool {
// `ghostty_surface_complete_clipboard_request`'s surface parameter is
// non-null on the Zig side; a request completing during surface
Expand Down
13 changes: 13 additions & 0 deletions Macterm/Model/SplitNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,11 @@ final class Pane: Identifiable {
private var agentIconPID: pid_t?

let searchState = TerminalSearchState()

/// The OSC 8 link URL under the mouse, for the pane's hover banner
/// (`GHOSTTY_ACTION_MOUSE_OVER_LINK`). Live UI state only — never
/// persisted.
var hoverURL: String?
var executionState: TerminalExecutionState = .idle {
didSet {
guard executionState != oldValue else { return }
Expand Down Expand Up @@ -885,6 +890,14 @@ final class Pane: Identifiable {
view.onOutputActivity = nil
view.onScrollbarUpdate = nil
view.onScrollWheel = nil
view.onLinkHover = nil
view.onMouseShapeChange = nil
view.onPromptTitle = nil
view.onSetTabTitle = nil
view.titleProvider = nil
// Release any secure-input scope this view holds (a pane closed
// mid-password-prompt must not leave the OS state stuck on).
view.passwordInput = false
view.destroySurface()
let scroll = _scrollView
_scrollView = nil
Expand Down
Loading
Loading