Skip to content

Add external Metal presenter ABI - #122

Merged
lawrencecchen merged 2 commits into
mainfrom
feat/cmux-external-metal-presenter
Jul 24, 2026
Merged

lawrencecchen merged 2 commits into
mainfrom
feat/cmux-external-metal-presenter

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jul 17, 2026 •

Copy link
Copy Markdown

Summary

  • add a Metal external-presenter platform mode that renders without owning a window or drawable
  • expose completed frames after GPU work finishes
  • allow PTY output teeing before Ghostty starts I/O
  • serialize configuration and atomically snapshot bounded VT recovery state

Verification

  • Ghostty ReleaseFast build and tests pass
  • exercised through cmux's real out-of-process render-worker integration test

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Add a Metal external-presenter mode that renders to IOSurfaces without owning a window and delivers completed frames via a callback. Also adds config serialization and an atomic VT+output-sequence snapshot for reliable out-of-process worker recovery, plus an initial PTY tee to avoid losing startup bytes.

  • New Features

    • New platform GHOSTTY_PLATFORM_METAL_EXTERNAL with ghostty_platform_metal_external_s and ghostty_metal_external_present_cb for IOSurface presentation after GPU completion.
    • Metal renderer supports an external presenter (no NSView/UIView); size comes from the runtime surface; present path invokes the embedder callback.
    • Initial PTY tee at surface creation via ghostty_surface_config_s (pty_tee_cb, pty_tee_userdata); setter still supported.
    • Serialize effective config with ghostty_config_serialize (returns config-file syntax).
    • Atomically snapshot VT tail with output position using ghostty_surface_read_screen_tail_vt_with_output_sequence; sequence advances only after bytes are applied and wraps on u64.
  • Bug Fixes

    • Command-palette actions with commas parse correctly via Binding.Action.parseCLI (e.g. write_screen_file:copy,vt).

Written for commit 0f400d0. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added external Metal rendering support for macOS and iOS embedding.
    • Added optional PTY output capture callbacks for embedders.
    • Added configuration serialization to Ghostty config-file format.
    • Added terminal screen-tail reading with output-sequence tracking.
    • Added command-line parsing support for binding actions.
  • Improvements

    • Improved rendering presentation flexibility and external surface sizing.
    • Ensured output sequence tracking advances only after terminal updates complete.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds callback-driven external Metal presentation, PTY tee configuration and output sequencing, configuration serialization, and CLI action parsing to the public and embedded APIs.

Changes

External Metal presentation

Layer / File(s) Summary
External Metal platform contract
include/ghostty.h, src/apprt/embedded.zig
Adds the metal_external platform, presentation callback contract, IOSurface data, C-ABI wiring, and callback validation.
Presenter lifecycle and frame dispatch
src/renderer/Metal.zig
Selects layer-based or external presentation and updates lifecycle, sizing, cleanup, and frame delivery paths.

PTY output interception and sequencing

Layer / File(s) Summary
PTY tee configuration and propagation
include/ghostty.h, src/termio/..., src/apprt/embedded.zig, src/Surface.zig
Threads optional PTY tee callbacks and userdata from surface options into Termio initialization, with accessors and default-value tests.
Processed output sequence snapshots
include/ghostty.h, src/termio/Termio.zig, src/apprt/embedded.zig
Tracks processed PTY bytes after terminal updates and exposes the position alongside successful VT-tail snapshots.

Configuration serialization

Layer / File(s) Summary
Configuration serialization and round trip
include/ghostty.h, src/config/CApi.zig
Adds configuration serialization through FileFormatter and verifies effective values round-trip without diagnostics.

Binding CLI parsing

Layer / File(s) Summary
CLI action parsing
src/input/Binding.zig
Adds Action.parseCLI for validating, parsing, and cloning binding actions from optional input.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Host
  participant EmbeddedSurface
  participant Metal
  participant IOSurface
  participant PresentCallback
  Host->>EmbeddedSurface: configure metal_external
  EmbeddedSurface->>Metal: initialize external presenter
  Metal->>IOSurface: render frame and obtain dimensions
  Metal->>PresentCallback: deliver IOSurface frame
Loading

Possibly related PRs

Suggested reviewers: mitchellh, lawrencecchen

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding an external Metal presenter ABI.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cmux-external-metal-presenter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/input/Binding.zig (1)

1327-1338: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add direct tests for Action.parseCLI.

The new bridge should test missing input (null → Error.InvalidFormat), comma-containing parameters such as write_screen_file:copy,vt, and successful cloning into the supplied allocator. The existing tests exercise the underlying parser but not this new API boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/input/Binding.zig` around lines 1327 - 1338, Add focused tests for
Action.parseCLI covering null input returning Error.InvalidFormat,
comma-containing actions such as write_screen_file:copy,vt parsing successfully,
and successful cloning into the provided allocator. Verify the cloned action is
usable and owned by the supplied allocator, while leaving the existing parser
tests unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@include/ghostty.h`:
- Around line 476-479: Version the public surface configuration ABI rather than
expanding it in place: in include/ghostty.h lines 476-479, keep the platform
payload pointer-sized or introduce a versioned configuration type; in
include/ghostty.h lines 531-534, move the appended PTY fields into a size-gated
extension or versioned options struct; mirror that representation in
src/apprt/embedded.zig lines 397-414; and update the surface_new_ parsing path
at src/apprt/embedded.zig lines 589-595 to read only fields covered by the
caller-provided size or version.

In `@src/renderer/Metal.zig`:
- Around line 291-297: Update the .external surface-size handling in Metal
renderer state to avoid directly reading external.surface.getSize(), since
Surface.size is concurrently updated by the embedder. Reuse the existing
synchronized renderer mailbox/state or atomic resize snapshot to obtain the
latest width and height, preserving the current size return structure.

In `@src/termio/Termio.zig`:
- Around line 79-88: Synchronize access to pty_tee_cb and pty_tee_userdata
across the compatibility setter and processOutput, preventing races and
mismatched callback/userdata pairs. Prefer routing setter updates through the IO
thread mailbox, or otherwise protect both fields as one atomic pair with a
shared lock; update the relevant setter and processOutput paths while preserving
callback execution behavior.

---

Nitpick comments:
In `@src/input/Binding.zig`:
- Around line 1327-1338: Add focused tests for Action.parseCLI covering null
input returning Error.InvalidFormat, comma-containing actions such as
write_screen_file:copy,vt parsing successfully, and successful cloning into the
provided allocator. Verify the cloned action is usable and owned by the supplied
allocator, while leaving the existing parser tests unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3d000984-1ee7-497d-866b-1419eb2c4cb4

📥 Commits

Reviewing files that changed from the base of the PR and between bb30526 and 0f400d0.

📒 Files selected for processing (8)
  • include/ghostty.h
  • src/Surface.zig
  • src/apprt/embedded.zig
  • src/config/CApi.zig
  • src/input/Binding.zig
  • src/renderer/Metal.zig
  • src/termio/Options.zig
  • src/termio/Termio.zig

Comment thread include/ghostty.h
Comment on lines 476 to +479
typedef union {
ghostty_platform_macos_s macos;
ghostty_platform_ios_s ios;
ghostty_platform_metal_external_s metal_external;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Version the public surface configuration instead of changing its layout in place.

The union expansion shifts existing fields, and the appended PTY fields increase the allocation size; surface_new_ then copies the complete new struct from callers that may still provide the old ABI layout.

  • include/ghostty.h#L476-L479: keep the platform payload pointer-sized or introduce a versioned configuration type.
  • include/ghostty.h#L531-L534: move these fields into a size-gated extension or versioned options struct.
  • src/apprt/embedded.zig#L397-L414: mirror the pointer-sized/versioned C representation.
  • src/apprt/embedded.zig#L589-L595: parse only fields guaranteed by the caller-provided size/version.
📍 Affects 2 files
  • include/ghostty.h#L476-L479 (this comment)
  • include/ghostty.h#L531-L534
  • src/apprt/embedded.zig#L397-L414
  • src/apprt/embedded.zig#L589-L595
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@include/ghostty.h` around lines 476 - 479, Version the public surface
configuration ABI rather than expanding it in place: in include/ghostty.h lines
476-479, keep the platform payload pointer-sized or introduce a versioned
configuration type; in include/ghostty.h lines 531-534, move the appended PTY
fields into a size-gated extension or versioned options struct; mirror that
representation in src/apprt/embedded.zig lines 397-414; and update the
surface_new_ parsing path at src/apprt/embedded.zig lines 589-595 to read only
fields covered by the caller-provided size or version.

Comment thread src/renderer/Metal.zig
Comment on lines +291 to +297
.external => |external| external_size: {
const value = try external.surface.getSize();
break :external_size .{
.width = value.width,
.height = value.height,
};
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Publish external surface dimensions through synchronized renderer state.

external.surface.getSize() reads Surface.size on the renderer thread while Surface.updateSize writes it from the embedder thread without synchronization. Publish resize values through the renderer mailbox/state or an atomic snapshot rather than reading the runtime struct directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/Metal.zig` around lines 291 - 297, Update the .external
surface-size handling in Metal renderer state to avoid directly reading
external.surface.getSize(), since Surface.size is concurrently updated by the
embedder. Reuse the existing synchronized renderer mailbox/state or atomic
resize snapshot to obtain the latest width and height, preserving the current
size return structure.

Comment thread src/termio/Termio.zig
Comment on lines +79 to +88
/// thread. The embedded runtime installs initial values before starting that
/// thread; the compatibility setter may replace them after surface creation.
pty_tee_cb: ?PtyTeeCallback = null,
pty_tee_userdata: ?*anyopaque = null,

/// Number of PTY-output bytes fully applied to terminal state. This is read
/// and updated only while renderer_state.mutex is held. Addition wraps so the
/// value remains a stable modulo-u64 stream position across long sessions.
processed_output_bytes: u64 = 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Data race hazard and torn read risk on pty_tee_cb and pty_tee_userdata.

The comment indicates that a compatibility setter may replace these fields after surface creation. However, processOutput reads both fields on the IO read thread without synchronization (before locking the renderer mutex).

If the setter mutates these fields from a different thread while the IO thread is running, this causes a data race. Furthermore, because the callback and the userdata are separate fields, concurrent modification could lead to a torn read where the new callback is invoked with the old userdata, likely causing a crash.

Consider one of the following fixes:

  1. Require the compatibility setter to dispatch the update via the IO thread's mailbox (so mutation and execution happen on the same thread).
  2. Protect these fields with an std.Thread.RwLock or a dedicated mutex.
  3. Bundle the callback and userdata into a single struct and update it using an atomic pointer exchange (if supported by the allocator pattern) or a lock.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/termio/Termio.zig` around lines 79 - 88, Synchronize access to pty_tee_cb
and pty_tee_userdata across the compatibility setter and processOutput,
preventing races and mismatched callback/userdata pairs. Prefer routing setter
updates through the IO thread mailbox, or otherwise protect both fields as one
atomic pair with a shared lock; update the relevant setter and processOutput
paths while preserving callback execution behavior.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR adds a Metal "external presenter" platform mode that lets an out-of-process embedder (e.g. cmux) receive rendered IOSurfaces via a callback rather than owning a window or CALayer, along with three companion features: a pre-surface-init PTY tee to prevent missed startup bytes, a ghostty_config_serialize API, and an atomic VT-tail-plus-sequence snapshot for crash-recovery replay.

  • Metal external presenter (Presenter union in Metal.zig, MetalExternal platform in embedded.zig, new enum value in ghostty.h): The present_callback fires from the Metal command-buffer completion handler after the GPU finishes, keeping the documented threading guarantee intact; loopEnter correctly skips the AppKit display-callback install for this path.
  • PTY tee at surface init (termio.Options, Surface.zig, embedded.zig): The tee is set before the IO thread starts so embedders cannot miss startup bytes; the existing post-create setter (ghostty_surface_set_pty_tee_cb) is preserved for compatibility.
  • ghostty_config_serialize / ghostty_surface_read_screen_tail_vt_with_output_sequence (CApi.zig, Termio.zig): The config serializer always prepends command-palette-entry = clear (documented in source but not in the C header); the sequence counter (processed_output_bytes) is correctly incremented inside the renderer mutex after terminal state is updated, and is read under the same lock by the new snapshot API.

Confidence Score: 4/5

Safe to merge; the core renderer, tee, and sequence-counter paths are well-tested and the mutex discipline is consistent throughout.

The Metal external presenter refactoring is clean and the threading contract holds for all realistic call paths. Two non-blocking issues remain: the C header for ghostty_config_serialize does not document the unconditional command-palette-entry = clear prefix the implementation emits, and the paired ptyTeeCallback/ptyTeeUserdata accessors use independent @hasDecl checks that could silently produce a mismatched pair.

include/ghostty.h (ghostty_config_serialize doc) and src/Surface.zig (paired hasDecl guards)

Important Files Changed

Filename Overview
include/ghostty.h Adds GHOSTTY_PLATFORM_METAL_EXTERNAL enum value, ghostty_platform_metal_external_s struct, ghostty_pty_tee_cb typedef (moved up), pty_tee_cb/pty_tee_userdata fields in surface config, ghostty_config_serialize, and ghostty_surface_read_screen_tail_vt_with_output_sequence. Header doc for ghostty_config_serialize omits the unconditional command-palette-entry prefix the implementation emits.
src/renderer/Metal.zig Introduces Presenter union (layer / external) refactoring the layer field into it. External path returns early in Presenter.init, sets no display callback in loopEnter, and calls present_callback from the command-buffer completion handler. sync is effectively always false for external, so the callback threading doc is accurate.
src/apprt/embedded.zig Adds MetalExternal platform variant, PtyTeeCallback type, pty_tee_cb/pty_tee_userdata fields to Surface and Options, ptyTeeCallback/ptyTeeUserdata accessors, and tests. Validation that present callback must be non-null is correct.
src/config/CApi.zig Adds ghostty_config_serialize / serializeConfig. Always prepends 'command-palette-entry = clear\n' before FileFormatter output; explained in source comments but not in the C header. The defer/toOwnedSlice pattern is idiomatic Zig. Round-trip test covers font-size, window-theme, cursor-opacity, and command-palette-entry.
src/termio/Termio.zig Adds processed_output_bytes counter (wrapping u64), updated always under renderer_state.mutex. Introduces processOutputAndAdvanceLocked to keep counter increment after processOutputLocked so observers never see bytes counted before terminal state reflects them.
src/termio/Options.zig Adds pty_tee_cb and pty_tee_userdata optional fields so the tee can be installed before the IO thread starts.
src/Surface.zig Wires pty_tee_cb and pty_tee_userdata from the apprt surface into termio.Options using separate comptime @hasDecl guards; the two guards are independent, which can silently produce a mismatched (callback, null-userdata) pair if only one accessor is declared.
src/input/Binding.zig Adds parseCLI bridge method on Action so the generic config struct parser can handle action parameters containing commas (e.g., write_screen_file:copy,vt in command-palette entries).

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Embedder as Embedder (cmux)
    participant CAPI as libghostty C API
    participant Surface as Surface.zig
    participant Termio as Termio.zig
    participant Metal as Metal.zig (Renderer)
    participant MTL as Metal GPU

    Embedder->>CAPI: ghostty_surface_new(config) pty_tee_cb set in config
    CAPI->>Surface: init() opts.pty_tee_cb Termio.init
    Surface->>Termio: IO thread starts with tee pre-installed
    Termio-->>Embedder: pty_tee_cb(bytes) before mutex raw PTY bytes

    Note over Metal,MTL: External presenter path
    Metal->>MTL: beginFrame commit command buffer
    MTL-->>Metal: addCompletedHandler fires completion thread
    Metal->>Metal: "present(target, sync=false)"
    Metal->>Embedder: present_callback(userdata, iosurface, w, h) borrowed IOSurfaceRef

    Note over CAPI,Termio: Atomic VT tail + sequence snapshot
    Embedder->>CAPI: ghostty_surface_read_screen_tail_vt_with_output_sequence
    CAPI->>CAPI: acquire renderer_state.mutex
    CAPI->>CAPI: readScreenTailVTLocked format terminal
    CAPI->>Termio: read processed_output_bytes mutex held
    CAPI-->>Embedder: returns vt_text next_sequence

    Note over CAPI: Config serialization
    Embedder->>CAPI: ghostty_config_serialize(config)
    CAPI->>CAPI: "prepend command-palette-entry = clear"
    CAPI->>CAPI: FileFormatter.format() all effective values
    CAPI-->>Embedder: ghostty_string_s caller frees with ghostty_string_free
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Embedder as Embedder (cmux)
    participant CAPI as libghostty C API
    participant Surface as Surface.zig
    participant Termio as Termio.zig
    participant Metal as Metal.zig (Renderer)
    participant MTL as Metal GPU

    Embedder->>CAPI: ghostty_surface_new(config) pty_tee_cb set in config
    CAPI->>Surface: init() opts.pty_tee_cb Termio.init
    Surface->>Termio: IO thread starts with tee pre-installed
    Termio-->>Embedder: pty_tee_cb(bytes) before mutex raw PTY bytes

    Note over Metal,MTL: External presenter path
    Metal->>MTL: beginFrame commit command buffer
    MTL-->>Metal: addCompletedHandler fires completion thread
    Metal->>Metal: "present(target, sync=false)"
    Metal->>Embedder: present_callback(userdata, iosurface, w, h) borrowed IOSurfaceRef

    Note over CAPI,Termio: Atomic VT tail + sequence snapshot
    Embedder->>CAPI: ghostty_surface_read_screen_tail_vt_with_output_sequence
    CAPI->>CAPI: acquire renderer_state.mutex
    CAPI->>CAPI: readScreenTailVTLocked format terminal
    CAPI->>Termio: read processed_output_bytes mutex held
    CAPI-->>Embedder: returns vt_text next_sequence

    Note over CAPI: Config serialization
    Embedder->>CAPI: ghostty_config_serialize(config)
    CAPI->>CAPI: "prepend command-palette-entry = clear"
    CAPI->>CAPI: FileFormatter.format() all effective values
    CAPI-->>Embedder: ghostty_string_s caller frees with ghostty_string_free
Loading

Comments Outside Diff (1)

  1. src/Surface.zig, line 108-115 (link)

    P2 Paired ptyTeeCallback/ptyTeeUserdata decls checked independently

    The two comptime @hasDecl guards are evaluated independently, so it is possible for an apprt surface to declare ptyTeeCallback without declaring ptyTeeUserdata (or vice versa). If that happens, the IO thread will be started with a non-null callback and a silently forced-null userdata, or a non-null userdata with no callback to receive it. Since both fields are always needed together, they should be validated as a pair: either both decls are present, or neither is. A compile-time assertion (or a single accessor that returns a struct with both fields) would catch any future mismatch early.

Reviews (1): Last reviewed commit: "feat: serialize worker recovery state" | Re-trigger Greptile

Comment thread include/ghostty.h
Comment on lines 1152 to +1155
GHOSTTY_API ghostty_config_t ghostty_config_new();
GHOSTTY_API void ghostty_config_free(ghostty_config_t);
GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t);
// Serialize the effective configuration as valid Ghostty config-file syntax.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Serialized output includes a non-value prefix line

The header doc says the returned bytes contain "all public values", but serializeConfig always prepends command-palette-entry = clear\n before the formatter runs. An embedder that pipes this output to a config diff tool or displays it as the "effective configuration" will see an unexpected directive at byte 0 that is not a public value. The source-level comment in CApi.zig explains the reason (clearing append-accumulation before re-emitting), but the C header—the contract for embedders—should make this explicit so callers are not surprised.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants