Skip to content

Cap embedded terminal scrollback - #114

Merged
azooz2003-bit merged 26 commits into
mainfrom
perf-typing-lag-scrollback-cap
Jul 14, 2026
Merged

azooz2003-bit merged 26 commits into
mainfrom
perf-typing-lag-scrollback-cap

Conversation

@azooz2003-bit

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

Copy link
Copy Markdown

Caps embedded terminal scrollback at 8 MiB per surface while retaining the current absolute-scroll-row restoration changes. This is the Ghostty dependency for manaflow-ai/cmux#7863.


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


Summary by cubic

Cap embedded surfaces’ scrollback via an embedder-owned limit (set to 8 MiB in cmux) and harden the renderer path to avoid missed frames and reduce typing lag. Adds an optional renderer activity callback for lightweight update/draw instrumentation.

  • New Features

    • Embedder scrollback cap per surface via ghostty_surface_new_with_scrollback_limit(...); the cap only lowers the configured scrollback-limit. Getter: ghostty_surface_scrollback_limit_bytes(...).
    • Optional renderer instrumentation callback (ghostty_renderer_event_cb) with events: UPDATE_FRAME_BEGIN/END and DRAW_FRAME_BEGIN/END; exposed as renderer_event_cb on ghostty_surface_config_s.
  • Bug Fixes

    • Coalesced visibility changes and exactly-once reveal rendering; retries draw submission when an app-thread mailbox push is dropped.
    • Skip frame rebuilds while hidden; rebuild once on reveal, then present immediately.
    • Retain prepared frame damage until draw commit to avoid unnecessary full redraws after draw errors.

Written for commit 14d4b0e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added optional renderer activity callbacks for embedders, including frame update and drawing events.
    • Added APIs to set and query an embedder-owned scrollback size limit.
    • Scrollback limits can now be reduced independently of user-configured settings.
  • Bug Fixes

    • Improved redraw reliability when message queues are temporarily full.
    • Improved visibility transitions and frame retry handling.
    • Ensured incomplete rendering work is retried rather than lost.

# Conflicts:
#	src/renderer/Thread.zig
@coderabbitai

coderabbitai Bot commented Jul 14, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Renderer and embedded surface controls

Layer / File(s) Summary
Renderer instrumentation contract
include/ghostty.h, src/renderer/..., src/apprt/embedded.zig, src/Surface.zig
Adds renderer event callbacks, embeds callback configuration in surface options, and passes instrumentation into renderer threads.
Embedded scrollback limit API
include/ghostty.h, src/apprt/embedded.zig
Adds capped surface construction, effective scrollback-limit calculation, and a query for the stored cap.
Mailbox redraw retry bridge
src/App.zig, src/Surface.zig
Records rejected redraw messages and notifies active surfaces after mailbox draining.
Visibility regain and retained draw flow
src/renderer/Thread.zig
Coalesces visibility changes and retries retained visibility-regain submissions when mailbox capacity returns.
Damage commit and render-state validation
src/renderer/generic.zig, src/terminal/Terminal.zig, src/terminal/render.zig
Makes frame damage retryable until commit and expands rendering-state tests.

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

Sequence Diagram(s)

sequenceDiagram
  participant AppMailbox
  participant App
  participant Surface
  participant RendererThread
  AppMailbox->>App: Reject redraw_surface when full
  App->>App: Store redraw retry request
  App->>App: Drain mailbox
  App->>Surface: Notify mailbox drained
  Surface->>RendererThread: Signal retained visibility retry
  RendererThread->>AppMailbox: Retry draw submission
Loading

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 clearly matches the main change: adding an embedded terminal scrollback cap.
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 perf-typing-lag-scrollback-cap

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.

@azooz2003-bit
azooz2003-bit merged commit a630590 into main Jul 14, 2026
103 of 104 checks passed
@azooz2003-bit
azooz2003-bit deleted the perf-typing-lag-scrollback-cap branch July 14, 2026 20:15
@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds two independently useful features to the cmux fork of Ghostty: (1) a new ghostty_surface_new_with_scrollback_limit C API that caps the embedded terminal's scrollback buffer at an embedder-supplied byte limit without modifying ghostty_surface_config_s's public layout, and (2) a content-free renderer-activity instrumentation callback (ghostty_renderer_event_cb) that fires update_frame_begin/end and draw_frame_begin/end events on the renderer thread. Alongside these features, the PR ships a significant refactor of the renderer thread's visibility-regain path: rapid hide/show transitions are now coalesced across a single mailbox drain, a generation-tagged VisibilityRegainState tracks retries when the app mailbox is full, and a dedicated visibility_retry async handles capacity-notification retries without polluting the normal draw path.

  • Scrollback cap: effectiveScrollbackLimit correctly enforces that a nonzero embedder cap can only lower (never raise) the user-configured limit; the new ghostty_surface_scrollback_limit_bytes accessor lets the embedder propagate the cap to child surfaces.
  • Visibility regain coalescing: VisibilityDrainState ensures only the final visible/hidden state from a batch of mailbox messages triggers a renderer rebuild; VisibilityRegainState with wrapping u64 generations prevents stale capacity notifications from resurrecting a hidden or torn-down surface.
  • DrawDamageCommit in generic.zig: a RAII guard ensures cells_rebuilt is reset on any failed draw path, so the next draw always starts with full damage rather than silently skipping it.

Confidence Score: 4/5

Safe to merge with awareness that on iOS the draw_frame_begin/end instrumentation events do not bracket actual GPU work; consumers must treat them as draw-queued signals rather than draw-completed signals.

The scrollback-cap feature is straightforward and well-tested. The visibility-regain coalescing is complex but has exhaustive inline tests covering full-mailbox retries, backend failures, vsync deferral, generation staleness, and stop-callback teardown. The only observable gap is that on the app-thread draw path (iOS), draw_frame_begin/end events fire on the renderer thread immediately after the message is queued, not when the GPU frame actually renders.

src/renderer/Thread.zig (drawFrame must_draw_from_app_thread instrumentation branch); src/apprt/embedded.zig (rendererInstrumentation wiring)

Important Files Changed

Filename Overview
include/ghostty.h Adds renderer event enum/callback typedef and renderer_event_cb field to ghostty_surface_config_s, plus two new API functions for scrollback-limited surface creation and retrieval.
src/App.zig Adds app-level redraw_retry_requested atomic and broadcasts appMailboxDrained to all surfaces after a successful mailbox drain; includes recordRejectedRedraw/takeRedrawRetryRequest helpers with a covering test.
src/Surface.zig Threads redraw_retry_requested into App.Mailbox at construction, optionally extracts rendererInstrumentation from the apprt surface, passes it to Thread.init, and adds appMailboxDrained forwarding to the renderer thread.
src/apprt/embedded.zig Adds scrollback_limit_bytes and renderer_event_cb to Surface/Options, implements effectiveScrollbackLimit (caps without raising), exposes two new C-API exports, and includes well-targeted unit tests.
src/renderer/Thread.zig Major refactor adding VisibilityDrainState coalescing, VisibilityRegainState with generation-based retry, a dedicated visibility_retry xev.Async, and DrawFrameResult return values; comprehensive inline tests cover all edge cases.
src/renderer/generic.zig Introduces DrawDamageCommit RAII guard to ensure cells_rebuilt resets on any failed draw path, preventing stale damage from being silently dropped; covered by a new test.
src/renderer/instrumentation.zig New file: thin Instrumentation struct that routes content-free renderer events to an optional embedder callback; validated against the C header enum via checkGhosttyHEnum.
src/renderer.zig Re-exports Instrumentation, InstrumentationCallback, and InstrumentationEvent from the new instrumentation.zig module.
src/terminal/Terminal.zig Relaxes the selection-activity test from exact counter comparisons to relational checks, making the test resilient to internal counter representation changes.
src/terminal/render.zig Adds two new tests verifying that partial dirty rows accumulate correctly across renders and that a full-redraw dirty bit dominates row-level dirt.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant RT as Renderer Thread
    participant AppMB as App Mailbox
    participant AT as App Thread
    participant VR as visibility_retry async

    Note over RT: Surface becomes visible (.visible msg)
    RT->>RT: drainMailbox coalesces hide/show via VisibilityDrainState
    RT->>RT: "applyRendererVisibilityTransition -> updateVisibilityRegainFrame()"
    RT->>AppMB: push(redraw_surface)
    alt Mailbox full
        AppMB-->>RT: push returns 0 (app_mailbox_full)
        RT->>RT: "VisibilityRegainState.pending = true, generation = G1"
        RT->>RT: renderAfterMailboxDrain - one immediate retry (also full)
        Note over RT: Frame retained, awaiting capacity
        AT->>AT: "drainMailbox -> takeRedrawRetryRequest"
        AT->>RT: appMailboxDrained() stores G1, notifies visibility_retry
        VR-->>RT: visibilityRetryCallback fires
        RT->>RT: "retrySubmission(G1) -> drawFrame(forced)"
        RT->>AppMB: push(redraw_surface) second attempt
        AppMB-->>AT: "processes redraw_surface -> GPU draw"
        RT->>RT: cancel pending, syncDrawTimer
    else Mailbox accepted
        AppMB-->>AT: "processes redraw_surface -> GPU draw"
        RT->>RT: cancel pending, normal draw cycle resumes
    end
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 RT as Renderer Thread
    participant AppMB as App Mailbox
    participant AT as App Thread
    participant VR as visibility_retry async

    Note over RT: Surface becomes visible (.visible msg)
    RT->>RT: drainMailbox coalesces hide/show via VisibilityDrainState
    RT->>RT: "applyRendererVisibilityTransition -> updateVisibilityRegainFrame()"
    RT->>AppMB: push(redraw_surface)
    alt Mailbox full
        AppMB-->>RT: push returns 0 (app_mailbox_full)
        RT->>RT: "VisibilityRegainState.pending = true, generation = G1"
        RT->>RT: renderAfterMailboxDrain - one immediate retry (also full)
        Note over RT: Frame retained, awaiting capacity
        AT->>AT: "drainMailbox -> takeRedrawRetryRequest"
        AT->>RT: appMailboxDrained() stores G1, notifies visibility_retry
        VR-->>RT: visibilityRetryCallback fires
        RT->>RT: "retrySubmission(G1) -> drawFrame(forced)"
        RT->>AppMB: push(redraw_surface) second attempt
        AppMB-->>AT: "processes redraw_surface -> GPU draw"
        RT->>RT: cancel pending, syncDrawTimer
    else Mailbox accepted
        AppMB-->>AT: "processes redraw_surface -> GPU draw"
        RT->>RT: cancel pending, normal draw cycle resumes
    end
Loading

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread src/renderer/Thread.zig
@@ -652,38 +908,54 @@ fn drawFrame(self: *Thread, now: bool) void {
// `render_now` no-op, permanently blanking the surface. macOS keeps the

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 draw_frame_begin/end events fire before the actual draw on iOS

On the must_draw_from_app_thread path (iOS), both draw_frame_begin and draw_frame_end are emitted synchronously on the renderer thread immediately after a successful app_mailbox.push, while the actual GPU draw occurs asynchronously on the app thread—potentially many milliseconds later. On the direct-draw path (else branch), the same events bracket the real renderer.drawFrame call. A cmux consumer using these events for frame-latency analysis or draw-phase gating will see near-zero draw durations on iOS even during heavy rendering, because the "end" fires before the GPU work starts.

Comment thread src/renderer/Thread.zig
Comment on lines +986 to 990
if (regain_was_pending and !t.visibility_regain.isPending()) {
t.syncDrawTimer();
}

// PageList mutations maintain their own compression dirty state. Checking

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 Display-link ticks silently dropped during pending visibility regain

When visibility_regain.isPending() is true, drawNowCallback (the iOS display-link path) returns .rearm without drawing. If the app mailbox remains full across multiple display-link ticks—for example, under sustained back-pressure where the app thread is slow to drain—the surface will stay blank for those frames. Recovery only happens via visibilityRetryCallback after appMailboxDrained fires. This is intentional, but the gap is unbounded by the renderer itself and depends entirely on app-thread mailbox drain latency.

@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: 1

🤖 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 `@src/terminal/Terminal.zig`:
- Around line 2350-2351: Update the test around t.screens.active.reset() to
re-select a live selection immediately before resetting, rather than relying on
switchScreen(.primary), which clears it. Assert that reset changes the selection
activity token as required by Screen.reset(), while preserving the existing
previous-token comparison.
🪄 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: f52b8e86-2e76-4b8b-bd87-bbdc8d073616

📥 Commits

Reviewing files that changed from the base of the PR and between fa64f7a and 14d4b0e.

📒 Files selected for processing (10)
  • include/ghostty.h
  • src/App.zig
  • src/Surface.zig
  • src/apprt/embedded.zig
  • src/renderer.zig
  • src/renderer/Thread.zig
  • src/renderer/generic.zig
  • src/renderer/instrumentation.zig
  • src/terminal/Terminal.zig
  • src/terminal/render.zig

Comment thread src/terminal/Terminal.zig
Comment on lines 2350 to +2351
t.screens.active.reset();
try testing.expectEqual(@as(SelectionActivity, 4), t.selectionActivity());
try testing.expectEqual(previous, t.selectionActivity());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant ranges in Terminal.zig
sed -n '2280,2370p' src/terminal/Terminal.zig
printf '\n---- switchScreen ----\n'
rg -n "switchScreen|selectionActivity|reset\(\)" src/terminal/Terminal.zig
printf '\n---- Screen.reset implementation context ----\n'
sed -n '3600,3705p' src/terminal/Terminal.zig

Repository: manaflow-ai/ghostty

Length of output: 9483


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the Screen.reset definition and nearby selection handling
rg -n "fn reset|selection|selectionActivity|active.reset|switchScreen" src/terminal/Terminal.zig

Repository: manaflow-ai/ghostty

Length of output: 2437


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the test and surrounding helper calls with line numbers
nl -ba src/terminal/Terminal.zig | sed -n '2320,2365p'

printf '\n---- selection-related definitions ----\n'
nl -ba src/terminal/Terminal.zig | sed -n '3600,3695p'

Repository: manaflow-ai/ghostty

Length of output: 197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Minimal proof for the disputed contract:
# - whether `switchScreen` clears selection
# - whether `reset()` touches selectionActivity / selection state
python3 - <<'PY'
from pathlib import Path
text = Path('src/terminal/Terminal.zig').read_text()
for needle in [
    'switchScreen',
    'selectionActivity',
    'active.reset()',
    'fn reset(',
]:
    print(f'== {needle} ==')
    for i, line in enumerate(text.splitlines(), 1):
        if needle in line:
            start = max(1, i-6)
            end = min(len(text.splitlines()), i+12)
            for j in range(start, end+1):
                print(f'{j:5d}: {text.splitlines()[j-1]}')
            print()
            break
PY

Repository: manaflow-ai/ghostty

Length of output: 2534


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect selection activity updates and reset/clearSelection behavior.
rg -n "selection_activity|clearSelection\(|selection_scroll|selection changes|advance.*selection|fetchAdd|store\(.release\)" src/terminal/Terminal.zig
printf '\n---- nearby around clearSelection ----\n'
python3 - <<'PY'
from pathlib import Path
text = Path('src/terminal/Terminal.zig').read_text().splitlines()
for needle in ['pub fn clearSelection', 'selection_activity', 'selection_scroll']:
    for i, line in enumerate(text, 1):
        if needle in line:
            start=max(1, i-12); end=min(len(text), i+40)
            print(f'\n== {needle} @ line {i} ==')
            for j in range(start, end+1):
                print(f'{j:5d}: {text[j-1]}')
            break
PY

Repository: manaflow-ai/ghostty

Length of output: 5104


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the screen reset implementation and inspect its body.
rg -n "pub fn reset\(|fn reset\(" src/terminal src | head -n 50
printf '\n---- around candidate reset definitions ----\n'
python3 - <<'PY'
from pathlib import Path
cands = []
for path in Path('src').rglob('*.zig'):
    text = path.read_text(errors='ignore').splitlines()
    for i, line in enumerate(text, 1):
        if 'fn reset(' in line or 'pub fn reset(' in line:
            cands.append((str(path), i))
for path, i in cands[:20]:
    print(f'## {path}:{i}')
    text = Path(path).read_text(errors='ignore').splitlines()
    start=max(1, i-12); end=min(len(text), i+45)
    for j in range(start, end+1):
        print(f'{j:5d}: {text[j-1]}')
    print()
PY

Repository: manaflow-ai/ghostty

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Also inspect the terminal reset path where active.reset() is called.
nl -ba src/terminal/Terminal.zig | sed -n '3810,3845p'

Repository: manaflow-ai/ghostty

Length of output: 197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

nl -ba src/terminal/Terminal.zig | sed -n '3810,3845p'

Repository: manaflow-ai/ghostty

Length of output: 197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '540,700p' src/terminal/Screen.zig
printf '\n---- token updates ----\n'
rg -n "selection_activity|fetchAdd|store\\(|clearSelection\\(" src/terminal/Screen.zig

Repository: manaflow-ai/ghostty

Length of output: 7069


Exercise reset with a live selection. switchScreen(.primary) clears the selection first, so t.screens.active.reset() only checks the already-clear path. Re-select immediately before reset and assert the token change required by Screen.reset().

🤖 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/terminal/Terminal.zig` around lines 2350 - 2351, Update the test around
t.screens.active.reset() to re-select a live selection immediately before
resetting, rather than relying on switchScreen(.primary), which clears it.
Assert that reset changes the selection activity token as required by
Screen.reset(), while preserving the existing previous-token comparison.

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.

1 participant