Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f355d15
test(next): S4 ON a drag shows each terminal size no later with motio…
lawrencecchen Nov 4, 2026
6b3e6c3
test(next): S4 ON a kept frame stays on the edges its S3 hold names (…
lawrencecchen Nov 4, 2026
b0a6f60
fix(next): S4 ON a kept frame rides the still edges of the S3 hold it…
lawrencecchen Nov 4, 2026
f863559
test(next): S4 ON the row-drag lag test drives the real layout path (…
lawrencecchen Nov 4, 2026
bda9658
debug(next): S4 the debug.motion trace records each terminal's presen…
lawrencecchen Nov 4, 2026
418d4a8
test(next): cmux-ad m6 1a: in a key resize each terminal shows its pa…
lawrencecchen Nov 4, 2026
73c411a
test(next): cmux-ad m6 1a: the key-resize frame test samples 1.5 s of…
lawrencecchen Nov 4, 2026
6cf19a0
test(next): cmux-ad m6 1a: the key-resize frame test steps the spring…
lawrencecchen Nov 4, 2026
1a88bac
test(next): cmux-ad m6 1a: the key-resize frame test checks each term…
lawrencecchen Nov 4, 2026
0ffa721
test(next): cmux-ad m6 1a: the S3-hold kept-frame case expects the to…
lawrencecchen Nov 4, 2026
b367fad
test(next): cmux-ad m6 1a: the key-resize frame test uses the app's P…
lawrencecchen Nov 4, 2026
51d5c39
fix(next): cmux-ad m6 1a: with motion.frameOwner.terminals on, a key …
lawrencecchen Nov 4, 2026
e37c317
Merge remote-tracking branch 'origin/feat-cmux-next' into scratch/fra…
lawrencecchen Nov 4, 2026
530c225
test: remove TerminalFrameOwnerDragCadenceTests (it never reproduced …
lawrencecchen Nov 4, 2026
b3ab63b
feat(next): motion.frameOwner.terminals on by default (FRAME OWNER S4…
lawrencecchen Nov 4, 2026
c642350
test(next): S4 kept-frame test builds after the feat merge (PaneMotio…
lawrencecchen Nov 4, 2026
f231b3c
revert(next): motion.frameOwner.terminals back to off until the key-r…
lawrencecchen Nov 4, 2026
2da280e
test(next): cmux-ad m6 (c): the key-resize terminal tests check pixel…
lawrencecchen Nov 4, 2026
1e0df52
feat(next): motion.frameOwner.terminals on by default (FRAME OWNER S4…
lawrencecchen Nov 4, 2026
665b187
release-notes: the key-resize terminal highlight is a fix (dogfood 36…
lawrencecchen Nov 4, 2026
712df02
test(next): S4 ON review of #35470: one grid when a frame waits at th…
lawrencecchen Nov 4, 2026
30eccfc
fix(next): S4 ON: a size in flight that is no longer wanted reports n…
lawrencecchen Nov 4, 2026
bb46d44
test(next): S4 ON review of #35470 (F3): a parked terminal shown duri…
lawrencecchen Nov 4, 2026
5d62688
fix(next): S4 ON: a terminal shown in a holding area mid-spring follo…
lawrencecchen Nov 4, 2026
ec9992f
test(next): S4 ON F1 test: p1 keeps holding its frames through the sp…
lawrencecchen Nov 4, 2026
cc2c26e
revert(next): take back the F3 change (5d62688a264): no frame shows i…
lawrencecchen Nov 4, 2026
738ab41
chore(next): S4 ON review items: debugState is debug-only, frame-owne…
lawrencecchen Nov 4, 2026
ded0cb0
test(next): cmux-ad m6 (c): TabStripFollowsPaneTickTests (b) with ter…
lawrencecchen Nov 4, 2026
447f784
feat(next): S4 ON PR lands with motion.frameOwner.terminals off by de…
lawrencecchen Nov 4, 2026
7abac06
test(next): S4 review of #35470 at 447f784c3cf (G1): the newest press…
lawrencecchen Nov 4, 2026
5aab496
fix(next): S4 ON: the newest key press's anchor wins over an older su…
lawrencecchen Nov 4, 2026
c4d6c93
test(next): S4 G1 (b) test takes the parked-tab path the review named…
lawrencecchen Nov 4, 2026
c0c7f9b
test(next): S4 keeps the default path's one-surface-size-per-press ch…
lawrencecchen Nov 4, 2026
162ea37
docs(next): S4 comments and the switch's help say what each state doe…
lawrencecchen Nov 4, 2026
6522020
test(next): S4 key-resize tests run with motion.frameOwner.terminals …
lawrencecchen 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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import QuartzCore
///
/// A container in it (``PaneMotionHoldFollowing``: a terminal's host, a browser's chrome; FRAME OWNER S3) takes the
/// area's bounds in every frame instead and hears the hold once, so its own ground covers the pane; only its producer
/// keeps its size.
/// keeps its size (a terminal's surface follows too with `motion.frameOwner.terminals` on, cmux-ad m6).
public final class PaneContentAreaView: NSView {
public override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ public protocol PaneMotionHolding: NSView {

/// A container in a pane's content area that follows the area in every frame of a hold (FRAME OWNER REFACTOR S3: a
/// terminal's host, a browser's chrome). The area gives it its own bounds in each frame, so its background, padding,
/// toolbar and scroller cover the pane; only its producer (the terminal surface, the page: pixels drawn on another
/// thread or in another process) keeps its size, on the hold's edges, and lays out once when the hold ends.
/// toolbar and scroller cover the pane. Its producer (the terminal surface, the page: pixels drawn on another thread or
/// in another process) keeps its size on the hold's edges and lays out once when the hold ends; with
/// `motion.frameOwner.terminals` on, a terminal's surface follows the area too (cmux-ad m6, S4: each size shows with
/// its frame, or the last frame stays, top-left).
@MainActor
public protocol PaneMotionHoldFollowing: NSView {
/// Whether it follows now (a page kind that cannot hold its pixels yet is held whole, as in S1).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ public nonisolated struct TerminalDragTunables {
default: true, code: "TerminalDragTunables.holdsSurfaceDuringDrag")

/// 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.
/// lands 1-4 refreshes later (FRAME OWNER REFACTOR P2), and a key resize holds the surface at one size per press.
/// On: ready-or-keep (stage S4): in every commit a terminal's bounds and its pixels change together, or both keep
/// their last size, and in a key resize the terminal shows its pane's size in every frame while its PTY gets one
/// grid per press (cmux-ad m6). It turns on by default when a held key resize with many terminals drops no more
/// refreshes with it on than off (each moving terminal costs one new IOSurface and one main wake per frame until
/// the Ghostty fork's coalesced frame-ready wake and render-target slack land). 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.",
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. In a key resize the terminal follows its pane in every frame, and its program gets one size per press. Off: the size changes at once and the frame comes 1-4 refreshes later, and a key resize keeps one terminal size per press.",
default: false, code: "TerminalDragTunables.framesOwnedByCommit")

public static var all: [TunableDescriptor] { [holdsSurfaceDuringDrag.descriptor, framesOwnedByCommit.descriptor] }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ final class TerminalFramePresenter {
/// Whether `view` waits for a present here.
func isWaiting(_ view: TerminalSurfaceView) -> Bool { requests[ObjectIdentifier(view)] != nil }

#if DEBUG || CMUX_BENCH_HOOKS
/// What this presenter holds for `view` (`debug.motion` trace): "request" (a size waits for its frame), "watch"
/// (its shown size changed outside a present), "commit" (its commit is reported at the next refresh), or "-".
func debugState(of view: TerminalSurfaceView) -> String {
let id = ObjectIdentifier(view)
if requests[id] != nil { return "request" }
if watched[id] != nil { return "watch" }
if commits[id] != nil { return "commit" }
return "-"
}
#endif

// MARK: Frames

/// One refresh: the commits of earlier passes are done (``commits``), and watched terminals are read.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ public import CmuxNextDesign

/// FRAME OWNER REFACTOR S3: in a keyboard resize the terminal host is a container
/// that follows its pane in every frame: the pane's content area gives it its bounds each frame, so the terminal's own
/// ground, padding, scroller and overlays cover the whole pane and the window's shell never shows inside it. Only the
/// surface (the producer: Ghostty draws it on its renderer thread) keeps its size: the final size the press asked for
/// (``holdSurface(size:anchor:)``, one grid per press), else the size it had when the hold began, fixed at the pane edge
/// that does not move. No grid and no reflow per frame: only the surface's origin moves.
/// ground, padding, scroller and overlays cover the whole pane and the window's shell never shows inside it. With
/// `motion.frameOwner.terminals` on (cmux-ad m6) the surface follows too: it takes the area it shows in, in every frame
/// (``followedSurfaceFrame(_:in:)``), and the PTY gets one grid per press. With the switch off only the surface's origin
/// moves: it keeps the final size the press asked for (``holdSurface(size:anchor:)``), else the size it had when the
/// hold began, fixed at the pane edge that does not move.
extension TerminalHostView: PaneMotionHoldFollowing {
public func followPaneMotion(_ hold: PaneMotionHold?, heldSize: CGSize?) {
followedHold = hold.map { ($0, heldSize ?? bounds.size) }
Expand All @@ -28,9 +29,16 @@ extension TerminalHostView: PaneMotionHoldFollowing {
/// (less a legacy scroller's strip). One exception to the still edge: a pane that grows down with its prompt in the
/// lower half (`motionAnchor` answered `.bottom`) keeps the surface's bottom on its moving bottom edge, so the prompt on
/// the final grid's last row stays in view; the surface is taller than the pane then, so it still covers it.
///
/// With `motion.frameOwner.terminals` on (cmux-ad m6: pixels per frame, the program's size per press) the surface takes
/// the area it shows in, in every frame, anchored top-left: the frame owner shows each size's frame with its bounds,
/// or keeps the last frame (clipped, top-left) until it is drawn; the PTY still gets one grid per press (the final
/// grid, ``TerminalSurfaceView`` `restAt`). Except the low prompt above (`restAt` held the surface on the bottom edge):
/// it keeps that hold.
func followedSurfaceFrame(_ followed: (hold: PaneMotionHold, heldSize: CGSize), in area: CGRect) -> CGRect { // perf-hot: 4ms motion-frame
let size = surfaceHold?.size ?? surfaceSize(forHostSize: followed.heldSize)
let shown = CGRect(x: area.minX, y: area.minY, width: max(0, area.width - scrollerWidth), height: area.height)
if TerminalDragTunables.framesOwnedByCommit.value, surfaceHold?.anchor != .bottom { return shown }
let size = surfaceHold?.size ?? surfaceSize(forHostSize: followed.heldSize)
var hold = followed.hold
if case .bottom? = surfaceHold?.anchor { hold.bottom = true }
return hold.producerFrame(size, in: shown)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,8 @@ public final class TerminalHostView: NSView {
current?.place(surfaceFrame, anchor: surfaceAnchor)
}

/// The spring landed: the surface takes the host's area again (the same size, unless the landing differs).
/// The hold ends: the spring landed, or (with `motion.frameOwner.terminals` on) a press whose terminal follows its
/// pane (`restAt`). The surface takes the host's area again (the same size, unless the landing differs).
func releaseSurfaceHold() {
guard surfaceHold != nil else { return }
surfaceHold = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ extension TerminalSurfaceView {
/// refreshes later. On: a new size goes to Ghostty and the view keeps its size, its old frame on the `anchor` edge
/// of `frame` (the host clips it and paints the terminal background beside it), until its window's commit shows
/// the frame drawn at the new size with the new bounds. One size waits at a time; the newest goes next.
func place(_ frame: CGRect, anchor: PaneMotionAnchor) {
func place(_ frame: CGRect, anchor: PaneMotionAnchor) { // perf-hot: 1ms motion-frame (each frame of a key resize)
let owned = TerminalDragTunables.framesOwnedByCommit.value
if owned != frameOwnership.embedderPresents { setFramesOwnedByCommit(owned) }
guard owned, isShown, window != nil, surface != nil else {
Expand Down Expand Up @@ -58,7 +58,7 @@ extension TerminalSurfaceView {
}

/// Asks Ghostty for a frame at the wanted size, keeps the view's size and waits for its window's commit.
func requestWantedFrame() {
func requestWantedFrame() { // perf-hot: 1ms motion-frame (each frame of a key resize)
guard frameOwnership.embedderPresents, let wanted = frameOwnership.wanted else { return }
guard isShown, let presenter = framePresenter else {
// Not shown (or no window): nothing to keep; the view takes the size and the scale it wants now.
Expand All @@ -80,7 +80,7 @@ extension TerminalSurfaceView {
/// Inside the commit: the view takes the size Ghostty's shown frame has, on the anchor edge of the wanted frame,
/// and its layer the same geometry (a held display scale included) in this commit. Returns whether it wants
/// another size since.
func landRequestedFrame() -> Bool {
func landRequestedFrame() -> Bool { // perf-hot: 1ms motion-frame (each frame of a key resize, under the CA lock)
applyHeldScale()
guard let size = frameOwnership.inFlight, let wanted = frameOwnership.wanted else { return false }
frameOwnership.inFlight = nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#if DEBUG || CMUX_BENCH_HOOKS
import AppKit
import CmuxNextDesign
import GhosttyNextKit
import IOSurface

extension TerminalSurfaceView {
/// Adds `motionSample` to `debug.motion`'s trace samplers, once: a static `let` runs its initializer one time (the
/// first surface view reads it).
static let motionSamplerRegistration: Void = MotionDebug.registeredSamplers.append(motionSample)

/// `debug.motion` `trace_*` sampler (FRAME OWNER S4 defect (a): which present path drew each terminal frame in a
/// drag): per display frame, each shown terminal surface as `terminal`:
/// `[x, y, width, height, shown width px, shown height px, frames, off-main presents, fresh presents,
/// display passes, main draws, late presents, owned, presenter, host frame]`.
/// The rect is the surface layer in window points with a top-left origin (the committed model); `shown` is the
/// IOSurface the layer shows (0 before the first frame); the counters are Ghostty's render counters, cumulative
/// (a frame that went through main is one of `frames` that is neither an off-main nor a fresh present);
/// `owned` is 1 while its window's commit owns its frame (`motion.frameOwner.terminals`); `presenter` is
/// ``TerminalFramePresenter/debugState(of:)`` (request, watch, commit or -); `host frame` is what
/// `ghostty_surface_host_frame` answers now without presenting (owned only, else "-"). It reads only while the trace
/// records; nothing runs when it does not.
static func motionSample(_ view: NSView) -> [String: [Any]]? {
guard let surfaceView = view as? TerminalSurfaceView else { return nil }
guard let window = surfaceView.window, let surface = surfaceView.surface, !surfaceView.isHiddenOrHasHiddenAncestor,
let layer = surfaceView.layer, let root = window.contentView?.layer else { return [:] }
let rect = layer.convert(layer.bounds, to: root)
let height = root.bounds.height
let top = root.isGeometryFlipped ? rect.minY : height - rect.maxY
func rounded(_ value: CGFloat) -> Double { (Double(value) * 100).rounded() / 100 }
let shown = surfaceView.presentedFrame
let counters = ghostty_surface_render_counters(surface)
let owned = surfaceView.frameOwnership.embedderPresents
let presenter = TerminalFramePresenter.forWindow(window).debugState(of: surfaceView)
let row: [Any] = [rounded(rect.minX), rounded(top), rounded(rect.width), rounded(rect.height),
shown?.width ?? 0, shown?.height ?? 0,
counters.frames, counters.host_off_main_presents, counters.host_fresh_presents,
counters.host_display_passes, counters.host_main_draws, counters.host_late_presents,
owned ? 1 : 0, presenter, owned ? Self.name(surfaceView.hostFrame(present: false)) : "-"]
return ["terminal": [row]]
}

private static func name(_ state: ghostty_host_frame_e) -> String {
switch state {
case GHOSTTY_HOST_FRAME_NONE: "none"
case GHOSTTY_HOST_FRAME_HIDDEN: "hidden"
case GHOSTTY_HOST_FRAME_SHOWN: "shown"
case GHOSTTY_HOST_FRAME_READY: "ready"
case GHOSTTY_HOST_FRAME_PRESENTED: "presented"
case GHOSTTY_HOST_FRAME_PENDING: "pending"
case GHOSTTY_HOST_FRAME_HELD: "held"
default: "other"
}
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ extension TerminalSurfaceView: PaneMotionTargeting {
return host.surfaceSize(forHostSize: hostSize)
}

/// Reports the grid a view of `size` points fits and holds the surface at that size in its host.
/// Reports the grid a view of `size` points fits and holds the surface at that size in its host. In a key resize
/// whose pane its host follows (FRAME OWNER S3) with `motion.frameOwner.terminals` on, only the grid goes (cmux-ad m6:
/// the program's size per press); the surface takes its pane's size in every frame
/// (``TerminalHostView/followedSurfaceFrame(_:in:)``), never held at the final size. One exception, as for a split
/// or a close: a pane that grows down with the prompt in its lower half (`motionAnchor` answers `.bottom`) keeps the
/// surface at its final size on the moving bottom edge, so the prompt on the final grid's last row stays in view (a
/// surface at the pane's size shows the final grid's top rows; review of #35470, F2).
private func restAt(_ size: NSSize, growing: Bool) -> PaneMotionAnchor {
guard let surface else { return .top }
let pixels = convertToBacking(size)
Expand All @@ -57,6 +63,15 @@ extension TerminalSurfaceView: PaneMotionTargeting {
else { return .top }
reportFinalGrid(TerminalGridSize(columns: Int(columns), rows: Int(rows)))
let anchor = motionAnchor(growing: growing)
if anchor == .top, let host = superview as? TerminalHostView, host.followedHold != nil,
TerminalDragTunables.framesOwnedByCommit.value {
// The newest press's anchor wins: a hold an earlier press or a join set (a low prompt's bottom hold, or the
// final size a terminal took before its area handed it the follow) goes now, not at the landing (review of
// #35470 at 447f784c3cf, G1: a stale bottom hold through a reverse press, and grid1, grid2, grid1, grid2 for
// two presses after a join).
host.releaseSurfaceHold()
return .top
}
(superview as? TerminalHostView)?.holdSurface(size: size, anchor: anchor)
return anchor
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ public final class TerminalSurfaceView: NSView {
super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600))
bridge.view = self
createSurface(mode: mode)
#if DEBUG || CMUX_BENCH_HOOKS
_ = Self.motionSamplerRegistration
#endif
}

@available(*, unavailable)
Expand Down Expand Up @@ -466,7 +469,13 @@ public final class TerminalSurfaceView: NSView {
// A pane on a layout spring (Cmd-Shift-Enter zoom, split, close) resizes this view on every
// display frame: the surface draws each size (the reflow preview), and the grid goes to the
// owner once the spring lands (`paneMotionDidSettle`), one PTY resize instead of one per frame.
let report = geometry.viewSized(desired, force: forceReport, holding: !forceReport && (isInPaneMotion || isFollowingResizeGesture))
// A size whose frame is not drawn yet and is no longer the one wanted (FRAME OWNER S4: the pane
// moved on, or its spring landed, while the frame waited) reports nothing: the wanted size
// reports its grid when it goes to Ghostty (`requestWantedFrame`). The landing's release sized
// from such a size and sent its stale grid, then the final grid again (review of #35470, F1).
let stale = frameOwnership.inFlight.map { $0 != frameOwnership.wanted?.size } ?? false
let report = geometry.viewSized(desired, force: forceReport,
holding: stale || (!forceReport && (isInPaneMotion || isFollowingResizeGesture)))
// In the same main-thread turn as set_size: the frame of the new size
// draws at the next display pass, already reflowed.
// A held grid is reported when the spring lands: the preview draws it meanwhile.
Expand Down
Loading
Loading