Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d643b86
test(motion): red: springs at each frame's show time; drags laid out …
lawrencecchen Nov 3, 2026
3df00d3
base red: RowBadgeSpinnerDensityTests' argument table is nonisolated …
lawrencecchen Nov 3, 2026
42966ad
test(motion): red: a frame after a 0.5 s hang moves the motion by the…
lawrencecchen Nov 3, 2026
d155549
fix(motion): springs at each frame's show time, drags laid out once p…
lawrencecchen Nov 3, 2026
a6355ec
test(next): a terminal resize commit never carries a layer size other…
lawrencecchen Nov 3, 2026
f67888f
fix(motion): S2 review fixes: a reopened window's first frame, zero-t…
lawrencecchen Nov 3, 2026
2d60bbe
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
af210bb
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
d49df53
test(motion): S2 Control trail test changes the keys inside one frame…
lawrencecchen Nov 3, 2026
678c4d9
Merge #33725 (S2 head d49df53e9f8) into fix/frame-owner-s4-terminals …
lawrencecchen Nov 3, 2026
da28dbc
GhosttyNextKit: pin 635c6c12a (ghostty-next #69 + #70: the embedder p…
lawrencecchen Nov 3, 2026
5fb50f2
fix(next): terminals ready-or-keep: bounds and pixels change in one c…
lawrencecchen Nov 3, 2026
9dbd196
fix(next): S4 presenter: the pass runs before every commit while a re…
lawrencecchen Nov 3, 2026
114c5ef
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
4b8b5b9
test(next): a Reduce Motion key resize never holds the main thread fo…
lawrencecchen Nov 3, 2026
9de73f4
test(next): S4 review: a terminal hidden while a size waits keeps its…
lawrencecchen Nov 3, 2026
3bb310c
fix(next): S4 review fixes: a hidden terminal drops its waiting size,…
lawrencecchen Nov 3, 2026
39b7b7f
fix(next): S4 review 2 nits: request cleared before the landing frame…
lawrencecchen Nov 3, 2026
da53c54
test(next): the hidden-terminal case lays its host out (it set a widt…
lawrencecchen Nov 3, 2026
c4d64b5
fix(next): S4 receipt review nits: a landing that made a request is n…
lawrencecchen Nov 3, 2026
749e27b
test(next): S4 key-turn case: each press waits until both terminals l…
lawrencecchen Nov 3, 2026
71b9d2f
test(next): S4 key-turn case fails and names the terminal and size wh…
lawrencecchen Nov 3, 2026
4870986
test(next): S4 main thread keeps turning when motion.frameOwner.termi…
lawrencecchen Nov 3, 2026
612d0bb
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
b9bafbd
GhosttyNextKit: pin 7d43bb68b (ghostty-next #71: an off-main present …
lawrencecchen Nov 3, 2026
1cc1424
test(next): S4 a new display scale lands with the frame drawn at it, …
lawrencecchen Nov 3, 2026
30df24f
fix(next): S4 a new display scale lands with the frame drawn at it, t…
lawrencecchen Nov 3, 2026
4b52920
test(next): the S4 hang case fails on a main-thread stall over 3 s th…
lawrencecchen Nov 3, 2026
15c8848
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
47281ea
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
f911a4d
Merge branch 'feat-cmux-next' into fix/frame-owner-s4-terminals
cmux-bot[bot] Nov 4, 2026
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
17 changes: 15 additions & 2 deletions Packages/Shared/CmuxGhosttyKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ import PackageDescription
// break reads a compressed history row only when the pair was joined, and
// a clear of rows (ED, DECALN) breaks the wrap across the cleared rows' edges.
//
// Since 34e73ba8b..635c6c12a (release/cmux-9ce9b7d0-e2, fork PRs #69 and #70):
// each link regex is compiled once per process (#69), and, opt-in on macOS
// (ghostty_surface_set_host_present EMBEDDER), the embedder shows a new host
// size's frame itself with ghostty_surface_host_frame in the transaction that
// gives the view its bounds, and reports its commit with
// ghostty_surface_host_frame_committed (#70; cmux FRAME OWNER REFACTOR S4,
// behind motion.frameOwner.terminals).
//
// Since 635c6c12a..7d43bb68b (release/cmux-9ce9b7d0-e2, fork PR #71): an
// off-main present never commits while it holds [CATransaction lock]. #70's
// embedder mode committed under the lock and deadlocked against main's
// commit (cmux S4 films with the switch on, STATUS 9732).
//
// A pin change is one reviewed commit that changes the URL and the checksum
// together (the zip's sha256, also in the release's SHA256SUMS). Never pin
// a7c40619a or 3e9dfca98 (apple-v6 without the lib prefix on the macOS
Expand All @@ -121,8 +134,8 @@ let package = Package(
),
.binaryTarget(
name: "GhosttyNextKit",
url: "https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-34e73ba8ba7cb8da0115caeb780065e3a12f2f62-apple-v6/GhosttyNextKit.xcframework.zip",
checksum: "32cb09bbc69510a099df9206a460cc8e7f2aba41a1a5489de98f185da07154a4"
url: "https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-7d43bb68b05e1bad433267fe28cd7205041fc4f5-apple-v6/GhosttyNextKit.xcframework.zip",
checksum: "12f7ac05eec4a0e0a47daef29545cdaba102e4b02ecafcc825d898ff71f4be2f"
),
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,14 @@ public nonisolated struct TerminalDragTunables {
help: "While a divider is dragged, each terminal keeps its size and text (clipped or padded) and reflows once when the drag lands. Off: the text reflows to the pane on every frame of the drag.",
default: true, code: "TerminalDragTunables.holdsSurfaceDuringDrag")

public static var all: [TunableDescriptor] { [holdsSurfaceDuringDrag.descriptor] }
/// Off (the default): a resized terminal's view takes its new bounds at once and Ghostty's frame of that size
/// lands 1-4 refreshes later (FRAME OWNER REFACTOR P2). On: ready-or-keep (stage S4): in every commit a
/// terminal's bounds and its pixels change together, or both keep their last size. Read on every layout, so one
/// build films both.
public static let framesOwnedByCommit = Tunable<Bool>.toggle(
"motion.frameOwner.terminals", section, "Terminal frame and size in one commit",
help: "A resized terminal shows its new size only in the commit that has its frame drawn at that size; until then it keeps its last size and frame. Off: the size changes at once and the frame comes 1-4 refreshes later.",
default: false, code: "TerminalDragTunables.framesOwnedByCommit")

public static var all: [TunableDescriptor] { [holdsSurfaceDuringDrag.descriptor, framesOwnedByCommit.descriptor] }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import AppKit
import CmuxNextCompat
import CmuxNextWakeups
import GhosttyNextKit
import QuartzCore

/// FRAME OWNER REFACTOR S4 (`motion.frameOwner.terminals`, ``TerminalDragTunables/framesOwnedByCommit``): one per
/// window. The present and the new bounds go together under `[CATransaction lock]` (Ghostty's call-site rule for
/// `ghostty_surface_host_frame`). A terminal whose size changes asks Ghostty for a frame at the new size (``TerminalSurfaceView/place(_:anchor:)``)
/// and keeps its old bounds and old frame. Just before Core Animation's commit of every main run-loop turn while a
/// request waits (a run-loop observer at ``passOrder``), the presenter's pass asks each waiting terminal for that frame
/// (`ghostty_surface_host_frame`): a drawn one becomes the layer's contents and the view takes the new bounds in that
/// same commit; one not drawn yet keeps both (ready-or-keep). A size asked during AppKit's display cycle (inside that
/// turn's commit) is presented from the next turn: the renderer draws it meanwhile. The main thread never waits for a frame. Chromium does the same at a resize: the browser compositor
/// shows a new size only with a frame the renderer submitted for it and keeps the old surface until then.
///
/// Requests of one refresh form a group that presents together for at most ``groupTicks`` refreshes, so two terminals
/// beside one divider land in the same commit. A request kept more than ``keepLimit`` refreshes takes its bounds
/// anyway (counted in ``Stats/fallbacks``). One refresh after a commit that changed a terminal's shown size, the presenter
/// tells Ghostty that commit is done (`ghostty_surface_host_frame_committed`), so the terminal's echo goes to the screen
/// off the main thread again. A new display scale is the same request: the layer keeps its scale until the frame drawn
/// at the new one shows (``TerminalSurfaceView/holdsScale(_:)``). The lock span holds only the present and the
/// geometry; what follows the new size runs after it (``TerminalSurfaceView/frameDidLand()``).
final class TerminalFramePresenter {
/// Refreshes a group of requests waits for its slowest member.
static let groupTicks: UInt64 = 2
/// Refreshes a request keeps its old size before it takes its bounds without its frame.
static let keepLimit: UInt64 = 6
/// The pass's run-loop order: just before Core Animation's commit (2,000,000), after the turn's other work.
static let passOrder: CFIndex = 1_999_999
/// Refreshes a terminal whose bounds changed without a present is watched for its frame.
static let watchLimit = 240

/// What the presenters did, cumulative (diagnostics and tests).
struct Stats: Equatable {
var passes = 0
/// Requests that took their bounds with their frame in one commit.
var presented = 0
/// Passes that kept a request at its old size (its frame was not drawn yet).
var keeps = 0
/// Passes that kept a ready request because another of its group was not ready.
var groupWaits = 0
/// Requests that took their bounds without their frame after ``keepLimit`` passes.
var fallbacks = 0
var longestPass: Duration = .zero
}

/// What this window's presenter did, cumulative.
private(set) var stats = Stats()

private struct Request {
weak var view: TerminalSurfaceView?
/// The refresh it was made in: its group.
var epoch: UInt64
}

private struct Watch {
weak var view: TerminalSurfaceView?
var ticks = 0
}

private weak var window: NSWindow?
private var requests: [ObjectIdentifier: Request] = [:]
/// Terminals whose shown size changed outside a present (a first frame, a reveal, a fallback), until their layer
/// shows the host size.
private var watched: [ObjectIdentifier: Watch] = [:]
/// Terminals whose shown size changed in a commit that is done by the next refresh.
private var commits: [ObjectIdentifier: Watch] = [:]
private var epoch: UInt64 = 0
/// The pass before each commit, installed while a request waits.
private var passObserver: MainRunLoopObserver?
private lazy var client = FrameClient(owner: "Terminal.framePresenter", isAnimation: false, scheduler: { [weak self] in
self?.window.map(FrameScheduler.forWindow) ?? .app
}) { [weak self] _ in
self?.tick() ?? false
}

private init(window: NSWindow) {
self.window = window
}

private static var associationKey: UInt8 = 0

/// `window`'s presenter, made on first use and kept by the window.
static func forWindow(_ window: NSWindow) -> TerminalFramePresenter {
if let presenter = objc_getAssociatedObject(window, &associationKey) as? TerminalFramePresenter { return presenter }
let presenter = TerminalFramePresenter(window: window)
objc_setAssociatedObject(window, &associationKey, presenter, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return presenter
}

/// `view` asked Ghostty for a frame at a new size: the next commits present it with its bounds, or keep both.
func request(_ view: TerminalSurfaceView) {
let id = ObjectIdentifier(view)
requests[id] = Request(view: view, epoch: epoch)
watched[id] = nil
armPass()
client.activate()
}

/// `view`'s shown size changed outside a present: its commit is reported once its layer shows the host size.
func watch(_ view: TerminalSurfaceView) {
let id = ObjectIdentifier(view)
guard requests[id] == nil else { return }
watched[id] = Watch(view: view)
client.activate()
}

/// `view` stops owning its frame through this window (the switch, another window).
func cancel(_ view: TerminalSurfaceView) {
let id = ObjectIdentifier(view)
requests[id] = nil
watched[id] = nil
commits[id] = nil
}

/// Whether `view` waits for a present here.
func isWaiting(_ view: TerminalSurfaceView) -> Bool { requests[ObjectIdentifier(view)] != nil }

// MARK: Frames

/// One refresh: the commits of earlier passes are done (``commits``), and watched terminals are read.
private func tick() -> Bool {
epoch &+= 1
if !commits.isEmpty {
for (_, done) in commits { done.view?.hostFrameCommitted() }
commits.removeAll(keepingCapacity: true)
}
for (id, watch) in watched {
guard let view = watch.view else {
watched[id] = nil
continue
}
switch view.hostFrame(present: false) {
case GHOSTTY_HOST_FRAME_SHOWN:
watched[id] = nil
commits[id] = watch
case GHOSTTY_HOST_FRAME_HIDDEN:
// Shown again, it is watched again (``TerminalSurfaceView/updateOcclusion()``).
watched[id] = nil
case GHOSTTY_HOST_FRAME_READY:
// Its bounds already have the host size (they were given without a present): the frame fits them.
CATransaction.lock()
_ = view.hostFrame(present: true)
CATransaction.unlock()
default:
var next = watch
next.ticks += 1
watched[id] = next.ticks > Self.watchLimit ? nil : next
}
}
return !requests.isEmpty || !watched.isEmpty || !commits.isEmpty
}

/// Installs the pass before each commit while a request waits. An observer never wakes the run loop: the
/// display link's ticks (``client``) bring a turn, and its commit, each refresh.
private func armPass() {
guard passObserver == nil else { return }
passObserver = MainRunLoopObserver(activities: [.beforeWaiting, .exit], order: Self.passOrder) { [weak self] _ in
self?.pass()
}
}

/// Before this turn's commit: each waiting terminal shows its frame at the new size with its new bounds, or keeps
/// both. It never waits; a terminal that lands short asks for its newest size here (a grid report, as any size).
func pass() { // perf-hot: 1ms motion-frame
guard !requests.isEmpty else {
passObserver = nil
return
}
let started = ContinuousClock.now
stats.passes += 1
// A group (the requests of one refresh) waits for its slowest member while it is younger than `groupTicks`.
var waitingGroups: [UInt64] = []
for (_, request) in requests where !waitingGroups.contains(request.epoch) {
guard let view = request.view else { continue }
if !Self.isReady(view.hostFrame(present: false)) { waitingGroups.append(request.epoch) }
}
for (id, request) in requests {
guard let view = request.view else {
requests[id] = nil
continue
}
let age = epoch &- request.epoch
if waitingGroups.contains(request.epoch), age < Self.groupTicks {
if Self.isReady(view.hostFrame(present: false)) { stats.groupWaits += 1 }
continue
}
// Ghostty's call-site rule: the present and the new bounds under the Core Animation lock, so no off-main
// commit of another terminal can ship this one's new frame with its old bounds in between.
CATransaction.lock()
let shown = view.hostFrame(present: true)
let presented = shown == GHOSTTY_HOST_FRAME_PRESENTED || shown == GHOSTTY_HOST_FRAME_SHOWN
// Cleared before the frame changes: a request the new frame makes for this view stays.
if presented { requests[id] = nil }
let followUp = presented ? view.landRequestedFrame() : false
CATransaction.unlock()
if presented {
stats.presented += 1
landed(view, id: id, watch: false, followUp: followUp)
continue
}
switch shown {
case GHOSTTY_HOST_FRAME_NONE, GHOSTTY_HOST_FRAME_HIDDEN:
// Nothing to keep: the bounds go now; a frame that comes later fits them.
land(view, id: id, watch: shown == GHOSTTY_HOST_FRAME_NONE)
default:
stats.keeps += 1
if age > Self.keepLimit {
stats.fallbacks += 1
land(view, id: id, watch: true)
}
}
}
if requests.isEmpty { passObserver = nil }
stats.longestPass = max(stats.longestPass, started.duration(to: .now))
}

/// The view takes the size its shown frame has; one that wants another size since asks for it now (a new
/// request, presented from the next turn).
private func land(_ view: TerminalSurfaceView, id: ObjectIdentifier, watch: Bool) {
requests[id] = nil
landed(view, id: id, watch: watch, followUp: view.landRequestedFrame())
}

private func landed(_ view: TerminalSurfaceView, id: ObjectIdentifier, watch: Bool, followUp: Bool) {
// A request the landing made for this view owns it; it is not watched beside it.
if watch, requests[id] == nil { watched[id] = Watch(view: view) } else if !watch { commits[id] = Watch(view: view) }
// Outside the lock span: what follows the new size and reads Ghostty under the terminal's lock.
view.frameDidLand()
if followUp { view.requestWantedFrame() }
}

private static func isReady(_ state: ghostty_host_frame_e) -> Bool {
state == GHOSTTY_HOST_FRAME_READY || state == GHOSTTY_HOST_FRAME_SHOWN
|| state == GHOSTTY_HOST_FRAME_NONE || state == GHOSTTY_HOST_FRAME_HIDDEN
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ public final class TerminalHostView: NSView {
func install(_ surfaceView: TerminalSurfaceView) {
let old = current
surfaceView.autoresizingMask = []
surfaceView.frame = surfaceFrame
surfaceView.place(surfaceFrame, anchor: surfaceAnchor)
// At the back: the scroller and the overlays stay over every swapped-in surface.
addSubview(surfaceView, positioned: .below, relativeTo: nil)
current = surfaceView
Expand Down Expand Up @@ -393,24 +393,27 @@ public final class TerminalHostView: NSView {
/// when it began; nil at rest.
var followedHold: (hold: PaneMotionHold, heldSize: CGSize)?

/// The edge a surface rides while its frame differs from the area's (a hold, or a size Ghostty has not shown yet).
private var surfaceAnchor: PaneMotionAnchor { surfaceHold?.anchor ?? .top }

/// Keeps the surface at `size`, the size it will have when its pane's layout spring lands (a split or a
/// close, Lawrence 2026-10-23): the terminal draws its final grid on every frame of the spring and reflows
/// once, not once per frame. `anchor`: the edge it rides (the bottom keeps a prompt on the last row in view).
func holdSurface(size: CGSize, anchor: PaneMotionAnchor) {
surfaceHold = (size, anchor)
if let current, current.frame != surfaceFrame { current.frame = surfaceFrame }
current?.place(surfaceFrame, anchor: surfaceAnchor)
}

/// The spring landed: the surface takes the host's area again (the same size, unless the landing differs).
func releaseSurfaceHold() {
guard surfaceHold != nil else { return }
surfaceHold = nil
if let current, current.frame != surfaceFrame { current.frame = surfaceFrame }
current?.place(surfaceFrame, anchor: surfaceAnchor)
}

public override func layout() { // perf-hot: 4ms motion-frame (each frame of a key resize the host follows, FRAME OWNER S3)
super.layout()
if let current, current.frame != surfaceFrame { current.frame = surfaceFrame }
current?.place(surfaceFrame, anchor: surfaceAnchor)
if let scroller {
let strip = scroller.strip(in: terminalBounds)
if scroller.frame != strip { scroller.frame = strip }
Expand Down
Loading
Loading