Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- Whole-codebase restructuring pass (internal, no behavior change): the remote-daemon stack moved out of `Workspace.swift`, browser data-import out of `BrowserPanel.swift`, v2 browser automation out of `TerminalController.swift`, UI-test harnesses out of `AppDelegate.swift`, and `TabManager`/`GhosttyNSView`/`ContentView` split into per-concern files — the largest source files shrank by 3,000–5,000 lines each, cutting incremental build times. The copy-pasted v1 telemetry-handler skeleton, agent-wrapper commands (Go and Swift), and boilerplate settings accessors were each collapsed onto single shared implementations.

### Fixed
- Shells and agent CLIs that rewrite the terminal title on every render (progress spinners, Claude Code) no longer flood the app with per-keystroke title updates — updates are coalesced to at most one per surface every 50ms, with the final title always delivered. Debug background logging also moved off the calling thread, so neither path can add typing latency anymore. (ported from upstream cmux)
- The app no longer crashes at launch on macOS 26+ when an SF Symbol is laid out before its window is visible — symbol raster sizes are now driven from an explicit frame instead of unresolved font metrics. (ported from upstream cmux)
- A terminal no longer goes blank until the next tab switch when an OSC completion notification toggles its unread ring — ring-only changes no longer rebind the terminal portal. (ported from upstream cmux)
- tmux-compat format strings now report real session/window identity: stable per-workspace session ids instead of `$0` everywhere, and only the actually-focused window claims `window_active`/`*` flags, so statuslines and scripts parsing across panes see correct state. (ported from upstream cmux)
- The bonsplit debug event log now uses non-throwing file APIs, removing a crash risk if the log file disappears mid-write. An opencode.json parse error also no longer leaks the user's home path into agent output.
- `programa.json`/`cmux.json` command configs now accept `//` and `/* */` comments and trailing commas, so a hand-edited config with a note like `// dev commands` no longer fails to load with a cryptic parse error.
- Release signing now proceeds inside-out without `--deep`, so the bundled `programa` and `ghostty` tools no longer inherit the app's camera, microphone, automation, JIT, or library-validation entitlements; the signed artifact is gated before notarization.
- Debug, Release, and Staging reload entrypoints now prepare GhosttyKit before building; Staging uses the canonical `Programa STAGING` name and `com.darkroom.programa.staging` identity.
Expand Down
12 changes: 12 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
NRSP0016A1B2C3D4E5F60719 /* GhosttySurfaceScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0015A1B2C3D4E5F60719 /* GhosttySurfaceScrollView.swift */; };
NRSP0014A1B2C3D4E5F60719 /* GhosttyNSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0013A1B2C3D4E5F60719 /* GhosttyNSView.swift */; };
NRSP0012A1B2C3D4E5F60719 /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0011A1B2C3D4E5F60719 /* GhosttyApp.swift */; };
B10A1CE2 /* BackgroundLogWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10A1CE1 /* BackgroundLogWriter.swift */; };
B10A1CE6 /* RenderableSystemSymbol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10A1CE5 /* RenderableSystemSymbol.swift */; };
B10A1CE4 /* GhosttyTitleUpdateDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10A1CE3 /* GhosttyTitleUpdateDispatcher.swift */; };
NRSP0010A1B2C3D4E5F60719 /* ContentView+SidebarResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0009A1B2C3D4E5F60719 /* ContentView+SidebarResizer.swift */; };
NRSP0008A1B2C3D4E5F60719 /* ColorSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0007A1B2C3D4E5F60719 /* ColorSupport.swift */; };
NRSP0006A1B2C3D4E5F60719 /* ClosedBrowserPanelRestoreSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0005A1B2C3D4E5F60719 /* ClosedBrowserPanelRestoreSnapshot.swift */; };
Expand Down Expand Up @@ -363,6 +366,9 @@
NRSP0015A1B2C3D4E5F60719 /* GhosttySurfaceScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceScrollView.swift; sourceTree = "<group>"; };
NRSP0013A1B2C3D4E5F60719 /* GhosttyNSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyNSView.swift; sourceTree = "<group>"; };
NRSP0011A1B2C3D4E5F60719 /* GhosttyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyApp.swift; sourceTree = "<group>"; };
B10A1CE1 /* BackgroundLogWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundLogWriter.swift; sourceTree = "<group>"; };
B10A1CE5 /* RenderableSystemSymbol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderableSystemSymbol.swift; sourceTree = "<group>"; };
B10A1CE3 /* GhosttyTitleUpdateDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleUpdateDispatcher.swift; sourceTree = "<group>"; };
NRSP0009A1B2C3D4E5F60719 /* ContentView+SidebarResizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SidebarResizer.swift"; sourceTree = "<group>"; };
NRSP0007A1B2C3D4E5F60719 /* ColorSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSupport.swift; sourceTree = "<group>"; };
NRSP0005A1B2C3D4E5F60719 /* ClosedBrowserPanelRestoreSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedBrowserPanelRestoreSnapshot.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -709,6 +715,7 @@
NRPA00010 /* SettingsView.swift */,
A5FF0017 /* SettingDefinition.swift */,
A5001012 /* ContentView.swift */,
B10A1CE5 /* RenderableSystemSymbol.swift */,
NRSP0083A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift */,
NRSP0081A1B2C3D4E5F60719 /* WorkspaceRemoteModels.swift */,
NRSP0079A1B2C3D4E5F60719 /* WorkspaceMountPresentation.swift */,
Expand All @@ -734,6 +741,8 @@
NRSP0015A1B2C3D4E5F60719 /* GhosttySurfaceScrollView.swift */,
NRSP0013A1B2C3D4E5F60719 /* GhosttyNSView.swift */,
NRSP0011A1B2C3D4E5F60719 /* GhosttyApp.swift */,
B10A1CE1 /* BackgroundLogWriter.swift */,
B10A1CE3 /* GhosttyTitleUpdateDispatcher.swift */,
NRSP0009A1B2C3D4E5F60719 /* ContentView+SidebarResizer.swift */,
NRSP0007A1B2C3D4E5F60719 /* ColorSupport.swift */,
NRSP0005A1B2C3D4E5F60719 /* ClosedBrowserPanelRestoreSnapshot.swift */,
Expand Down Expand Up @@ -1137,6 +1146,7 @@
NRPA00009 /* SettingsView.swift in Sources */,
A5FF0007 /* SettingDefinition.swift in Sources */,
A5001002 /* ContentView.swift in Sources */,
B10A1CE6 /* RenderableSystemSymbol.swift in Sources */,
NRSP0084A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift in Sources */,
NRSP0082A1B2C3D4E5F60719 /* WorkspaceRemoteModels.swift in Sources */,
NRSP0080A1B2C3D4E5F60719 /* WorkspaceMountPresentation.swift in Sources */,
Expand All @@ -1162,6 +1172,8 @@
NRSP0016A1B2C3D4E5F60719 /* GhosttySurfaceScrollView.swift in Sources */,
NRSP0014A1B2C3D4E5F60719 /* GhosttyNSView.swift in Sources */,
NRSP0012A1B2C3D4E5F60719 /* GhosttyApp.swift in Sources */,
B10A1CE2 /* BackgroundLogWriter.swift in Sources */,
B10A1CE4 /* GhosttyTitleUpdateDispatcher.swift in Sources */,
NRSP0010A1B2C3D4E5F60719 /* ContentView+SidebarResizer.swift in Sources */,
NRSP0008A1B2C3D4E5F60719 /* ColorSupport.swift in Sources */,
NRSP0006A1B2C3D4E5F60719 /* ClosedBrowserPanelRestoreSnapshot.swift in Sources */,
Expand Down
80 changes: 80 additions & 0 deletions Sources/BackgroundLogWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation

// MARK: - BackgroundLogWriter (ported from upstream cmux cb2129a5a1)
//
// Replaces the previous per-call FileManager.fileExists + FileHandle(forWritingTo:)
// open -> seekToEnd -> write -> close pattern, which ran synchronously under a lock
// on the calling thread (often the main thread) for every debug log line. Instruments
// showed this blocking appearance-config resolution during bursts of background log
// activity.
//
// This writer serializes all file I/O onto a single serial background queue with one
// long-lived FileHandle. Callers append lines asynchronously and never block. The log
// file path/format is unchanged so existing tooling that tails the log keeps working.

/// Minimal sink abstraction so the writer's queueing/coalescing logic can be tested
/// independently of real file I/O. Upstream splits this into a protocol for their
/// package's unit tests; programa has no separate test target for this file, so the
/// concrete `FileBackgroundLogLineSink` is the only conformer today.
protocol BackgroundLogLineSink: AnyObject {
func write(_ data: Data)
}

/// Owns a single long-lived `FileHandle` opened for appending, created lazily on first
/// write and reused for the lifetime of the sink. Must only be used from the writer's
/// serial queue.
final class FileBackgroundLogLineSink: BackgroundLogLineSink {
private let url: URL
private var handle: FileHandle?

init(url: URL) {
self.url = url
}

func write(_ data: Data) {
if handle == nil {
handle = Self.openHandle(at: url)
}
guard let handle else { return }
do {
try handle.seekToEnd()
try handle.write(contentsOf: data)
} catch {
// The handle may have become invalid (e.g. file removed out from under us).
// Drop it so the next write reopens rather than repeatedly failing.
try? handle.close()
self.handle = nil
}
}

private static func openHandle(at url: URL) -> FileHandle? {
if FileManager.default.fileExists(atPath: url.path) == false {
_ = FileManager.default.createFile(atPath: url.path, contents: nil)
}
return try? FileHandle(forWritingTo: url)
}
}

/// Appends pre-formatted log lines to disk on a single serial background queue,
/// so callers (which may be on the main thread) never block on file I/O.
final class BackgroundLogWriter {
private let queue = DispatchQueue(label: "com.darkroom.programa.background-log-writer", qos: .utility)
private let sink: BackgroundLogLineSink

convenience init(url: URL) {
self.init(sink: FileBackgroundLogLineSink(url: url))
}

init(sink: BackgroundLogLineSink) {
self.sink = sink
}

/// Appends `line` to the log file asynchronously. Safe to call from any thread;
/// never blocks the caller on file I/O.
func append(_ line: String) {
guard let data = line.data(using: .utf8) else { return }
queue.async { [sink] in
sink.write(data)
}
}
}
2 changes: 1 addition & 1 deletion Sources/DebugWindows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ private struct DebugWindowControlsView: View {
Text(localizedDebugLabel("Preview"))
Spacer()
Image(systemName: selectedDevToolsIconOption.rawValue)
.font(.system(size: 12, weight: .medium))
.symbolRasterSize(12, weight: .medium)
.foregroundStyle(selectedDevToolsColorOption.color)
}

Expand Down
43 changes: 21 additions & 22 deletions Sources/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,15 @@ class GhosttyApp {
}()
private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL()
private let backgroundLogStartUptime = ProcessInfo.processInfo.systemUptime
// Guards only the cheap `backgroundLogSequence` increment below; the actual file
// write is offloaded to `backgroundLogWriter`'s serial queue (ported from upstream
// cmux cb2129a5a1) so callers never block on FileHandle open/seek/write/close.
private let backgroundLogLock = NSLock()
private var backgroundLogSequence: UInt64 = 0
// Non-lazy: `handleAction`/`logBackground` run on Ghostty callback threads, and
// `lazy var` initialization is not thread-safe.
private let backgroundLogWriter: BackgroundLogWriter
private let titleUpdateDispatcher = GhosttyTitleUpdateDispatcher()
private var appObservers: [NSObjectProtocol] = []
private var bellAudioSound: NSSound?
private var backgroundEventCounter: UInt64 = 0
Expand All @@ -230,6 +237,7 @@ class GhosttyApp {
})

private init() {
backgroundLogWriter = BackgroundLogWriter(url: backgroundLogURL)
initializeGhostty()
}

Expand Down Expand Up @@ -1690,17 +1698,17 @@ class GhosttyApp {
.flatMap { String(cString: $0) } ?? ""
if let tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .ghosttyDidSetTitle,
object: surfaceView,
userInfo: [
GhosttyNotificationKey.tabId: tabId,
GhosttyNotificationKey.surfaceId: surfaceId,
GhosttyNotificationKey.title: title,
]
)
}
// Coalesced (ported from upstream cmux c30733e5e6): shells/agent CLIs
// that rewrite the title on every render would otherwise flood the
// main actor with one NotificationCenter post per keystroke. The
// dispatcher guarantees the last title set within the window is
// always delivered.
titleUpdateDispatcher.setTitle(
surfaceView: surfaceView,
tabId: tabId,
surfaceId: surfaceId,
title: title
)
}
return true
case GHOSTTY_ACTION_PWD:
Expand Down Expand Up @@ -1975,20 +1983,11 @@ class GhosttyApp {
let frame120 = Int((CACurrentMediaTime() * 120.0).rounded(.down))
let threadLabel = Thread.isMainThread ? "main" : "background"
backgroundLogLock.lock()
defer { backgroundLogLock.unlock() }
backgroundLogSequence &+= 1
let sequence = backgroundLogSequence
backgroundLogLock.unlock()
let line =
"\(timestamp) seq=\(sequence) t+\(String(format: "%.3f", uptimeMs))ms thread=\(threadLabel) frame60=\(frame60) frame120=\(frame120) cmux bg: \(message)\n"
if let data = line.data(using: .utf8) {
if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false {
_ = FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil)
}
if let handle = try? FileHandle(forWritingTo: backgroundLogURL) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
}
}
backgroundLogWriter.append(line)
}
}
5 changes: 3 additions & 2 deletions Sources/GhosttyTerminalView+SwiftUIWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
let coordinator = context.coordinator
let previousDesiredIsActive = coordinator.desiredIsActive
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing
let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority
let desiredStateChanged =
previousDesiredIsActive != isActive ||
Expand Down Expand Up @@ -351,12 +350,14 @@ struct GhosttyTerminalView: NSViewRepresentable {
let hostId = ObjectIdentifier(host)
let geometryRevision = host.geometryRevision
let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
// Notification rings are hosted inside GhosttySurfaceScrollView and update in place.
// A ring-only state change must not resynchronize the window portal while SwiftUI is
// invalidating notification UI, or the terminal can be hidden until the next tab switch.
let shouldBindNow =
coordinator.lastBoundHostId != hostId ||
hostedView.superview == nil ||
portalEntryMissing ||
previousDesiredIsVisibleInUI != isVisibleInUI ||
previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing ||
previousDesiredPortalZPriority != portalZPriority
if portalBindingLive && shouldBindNow {
#if DEBUG
Expand Down
Loading
Loading