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
7 changes: 3 additions & 4 deletions .github/workflows/build-ghosttykit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,10 @@ jobs:
set -euo pipefail
rm -rf GhosttyKit.xcframework
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
# ghostty has used both libghostty.a and libghostty-fat.a archive names
# across revisions; accept either.
ARCHIVE="$(find GhosttyKit.xcframework -type f \( -name 'libghostty.a' -o -name 'libghostty-fat.a' \) -print -quit)"
# Ghostty has used several archive names across revisions.
ARCHIVE="$(find GhosttyKit.xcframework -type f \( -name 'libghostty.a' -o -name 'libghostty-fat.a' -o -name 'libghostty-internal-fat.a' \) -print -quit)"
if [ -z "$ARCHIVE" ]; then
echo "GhosttyKit.xcframework does not contain libghostty.a or libghostty-fat.a" >&2
echo "GhosttyKit.xcframework does not contain a supported macOS libghostty archive" >&2
exit 1
fi
./scripts/verify-release-architectures.sh "$ARCHIVE"
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- A restart after an update no longer kills every terminal when the new app comes up faster than the background session-holder notices the old one is gone. The app now waits out that window instead of giving up, escrow sockets no longer leak into shell processes (which silently delayed that detection), and a session that falls back anyway keeps its reattach records on disk while its process is still alive instead of deleting them.

### Changed
- Hidden terminal panes now release their Metal renderer and IOSurface pool after a short idle period while keeping the shell, scrollback, and terminal state alive. Returning to the pane rebuilds its renderer before it becomes visible, so graphics memory scales with the terminals on screen instead of every workspace opened during the session.
- Terminal output subscriptions now take one bounded snapshot per surface and publish only the changed suffix, reducing main-thread work and memory churn for automation clients watching busy terminals.
- Less background churn under agent load: repeated identical progress and port reports no longer redraw workspaces, moving the mouse across a window no longer re-renders its chrome, and scrolling no longer builds debug strings that get thrown away.
- Closing a browser tab now clears its leftover automation state (scripts, dialog queues, download logs), and a browser download wait that times out no longer risks corrupting a file handle.
Expand Down
8 changes: 8 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
NRPA00005 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00006 /* SettingsModels.swift */; };
NRPA00007 /* SettingsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00008 /* SettingsComponents.swift */; };
NRPA00009 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00010 /* SettingsView.swift */; };
REND0002A1B2C3D4E5F60719 /* RendererRealization.swift in Sources */ = {isa = PBXBuildFile; fileRef = REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */; };
A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; };
A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; };
NRSP0084A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0083A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift */; };
Expand Down Expand Up @@ -304,6 +305,7 @@
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; };
3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; };
SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; };
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
Expand Down Expand Up @@ -431,6 +433,7 @@
NRPA00006 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
NRPA00008 /* SettingsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComponents.swift; sourceTree = "<group>"; };
NRPA00010 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealization.swift; sourceTree = "<group>"; };
A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = "<group>"; };
A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
NRSP0083A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarModels.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -718,6 +721,7 @@
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = "<group>"; };
D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = "<group>"; };
SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = "<group>"; };
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -899,6 +903,7 @@
NRPA00006 /* SettingsModels.swift */,
NRPA00008 /* SettingsComponents.swift */,
NRPA00010 /* SettingsView.swift */,
REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */,
A5FF0017 /* SettingDefinition.swift */,
A5001012 /* ContentView.swift */,
B10A1CE5 /* RenderableSystemSymbol.swift */,
Expand Down Expand Up @@ -1242,6 +1247,7 @@
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */,
D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */,
SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */,
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
Expand Down Expand Up @@ -1456,6 +1462,7 @@
NRPA00005 /* SettingsModels.swift in Sources */,
NRPA00007 /* SettingsComponents.swift in Sources */,
NRPA00009 /* SettingsView.swift in Sources */,
REND0002A1B2C3D4E5F60719 /* RendererRealization.swift in Sources */,
A5FF0007 /* SettingDefinition.swift in Sources */,
A5001002 /* ContentView.swift in Sources */,
B10A1CE6 /* RenderableSystemSymbol.swift in Sources */,
Expand Down Expand Up @@ -1717,6 +1724,7 @@
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */,
3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */,
SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */,
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
installBrowserAddressBarFocusObservers()
installShortcutMonitor()
installShortcutDefaultsObserver()
RendererRealizationController.shared.start()
NSApp.servicesProvider = self
#if DEBUG
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
Expand Down
12 changes: 11 additions & 1 deletion Sources/GhosttySurfaceScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,11 @@ final class GhosttySurfaceScrollView: NSView {

func attachSurface(_ terminalSurface: TerminalSurface) {
surfaceView.attachSurface(terminalSurface)
// A visibility update can arrive before the runtime surface is attached.
// Re-apply the current effective state so the renderer mirror and Ghostty
// occlusion state always start in sync with this host view.
lastRequestedPortalOcclusionVisible = nil
applyEffectiveOcclusion()
}

func setFocusHandler(_ handler: (() -> Void)?) {
Expand Down Expand Up @@ -1731,7 +1736,12 @@ final class GhosttySurfaceScrollView: NSView {
#endif
guard lastRequestedPortalOcclusionVisible != effective else { return }
lastRequestedPortalOcclusionVisible = effective
surfaceView.terminalSurface?.setOcclusion(effective)
let terminalSurface = surfaceView.terminalSurface
terminalSurface?.setRendererPortalVisible(effective)
if effective {
terminalSurface?.realizeRenderer()
}
terminalSurface?.setOcclusion(effective)
#if DEBUG
dlog(
"terminal.occlusion surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
Expand Down
119 changes: 119 additions & 0 deletions Sources/RendererRealization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import AppKit
import Foundation

enum RendererRealizationSettings {
struct Values: Equatable, Sendable {
let enabled: Bool
let idleSeconds: TimeInterval
let maxWarmRenderers: Int
}

// Reclamation is non-destructive: only Ghostty's Metal swap chain is released.
// The PTY, terminal state, scrollback, and surface wrapper remain alive.
static let current = Values(enabled: true, idleSeconds: 30, maxWarmRenderers: 1)
}

struct RendererRealizationPlannerInput: Sendable {
let surfaceId: UUID
let isVisible: Bool
let isRealized: Bool
let lastVisibleAt: TimeInterval
}

enum RendererRealizationPlanner {
static func selectedSurfaceIds(
inputs: [RendererRealizationPlannerInput],
settings: RendererRealizationSettings.Values,
now: TimeInterval
) -> Set<UUID> {
guard settings.enabled else { return [] }

let ranked = inputs
.filter(\.isRealized)
.sorted { lhs, rhs in
if lhs.lastVisibleAt == rhs.lastVisibleAt {
return lhs.surfaceId.uuidString < rhs.surfaceId.uuidString
}
return lhs.lastVisibleAt > rhs.lastVisibleAt
}

let warmCap = max(1, settings.maxWarmRenderers)
var selected: Set<UUID> = []
for (index, input) in ranked.enumerated() {
if index < warmCap || input.isVisible { continue }
guard now - input.lastVisibleAt >= settings.idleSeconds else { continue }
selected.insert(input.surfaceId)
}
return selected
}
}

/// Releases hidden terminal Metal swap chains after a short idle window while
/// leaving each terminal's PTY and state alive. A revealed surface rebuilds its
/// renderer before it is marked visible again.
@MainActor
final class RendererRealizationController {
static let shared = RendererRealizationController()

private let timerQueue = DispatchQueue(label: "com.darkroom.programa.renderer-realization", qos: .utility)
private var timer: DispatchSourceTimer?
private var hasScheduledImmediatePass = false

private init() {}

func start() {
guard timer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.schedule(deadline: .now() + 10, repeating: 20)
timer.setEventHandler {
let now = Date()
Task { @MainActor in
RendererRealizationController.shared.evaluate(now: now)
}
}
timer.resume()
self.timer = timer
}

func scheduleImmediatePass() {
guard !hasScheduledImmediatePass else { return }
hasScheduledImmediatePass = true
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
RendererRealizationController.shared.evaluate(now: Date())
RendererRealizationController.shared.hasScheduledImmediatePass = false
}
}

func evaluate(now: Date) {
let settings = RendererRealizationSettings.current
guard settings.enabled else { return }

let surfaces = TerminalSurfaceRegistry.shared.allSurfaces()
for surface in surfaces where surface.isRendererPortalVisible {
surface.noteBecameVisibleForRendererReclamation()
if surface.hasLiveSurface, !surface.isRendererRealized {
surface.realizeRenderer()
}
}

let inputs = surfaces.compactMap { surface -> RendererRealizationPlannerInput? in
guard surface.hasLiveSurface else { return nil }
return RendererRealizationPlannerInput(
surfaceId: surface.id,
isVisible: surface.isRendererPortalVisible,
isRealized: surface.isRendererRealized,
lastVisibleAt: surface.rendererLastVisibleAt
)
}

let selected = RendererRealizationPlanner.selectedSurfaceIds(
inputs: inputs,
settings: settings,
now: now.timeIntervalSince1970
)
for surface in surfaces where selected.contains(surface.id) {
surface.releaseRenderer()
}
}
}
Loading
Loading