Skip to content

Fix offscreen terminal helper PTY startup - #4233

Merged
lawrencecchen merged 10 commits into
mainfrom
issue-4228-terminal-helper-surfaces-no-pty
May 16, 2026
Merged

lawrencecchen merged 10 commits into
mainfrom
issue-4228-terminal-helper-surfaces-no-pty

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented May 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4228.

Summary:

  • Start terminal runtimes for startup/cold input through a hidden bootstrap window when no real window is attached yet.
  • Queue cold socket text/key input instead of returning OK after dropping it.
  • Return localized socket errors when a terminal has exited, is unavailable, or the cold input queue is full.
  • Add regression coverage for offscreen startup, cold input queueing, newline key queueing, lifecycle closure, teardown, and queue overflow behavior.

Reproduction:

  • Reproduced against the caller workspace on the installed app: new-surface --workspace ... --pane pane:123 --type terminal --focus false returned OK surface:232, send returned OK, then read-screen --surface surface:232 returned ERROR: Terminal surface not found and top --processes showed surface:232 had no child process.
  • Reproduced again while preparing the tagged handoff: new-pane --workspace ... --type terminal --direction right --focus false returned OK surface:279, then read-screen --surface surface:279 returned ERROR: Terminal surface not found and top --processes showed no child process.

Red test:

  • Commit 32b3ee199 failed on cloud Mac with TerminalOffscreenStartupTests: cold input had pending.items == 0, daemon newline input had keyEvents == 0, and startup input made createAttemptCount == 0.
  • Result bundle: /tmp/cmux-4228-red-unit2.xcresult on cloud-mac-25947691246.

Green tests:

  • Commit 06f1c0961 passed TerminalOffscreenStartupTests: 9 tests, 0 failures.
  • Result bundle: /tmp/cmux-4228-actor-helper-unit.xcresult.
  • Commit 06f1c0961 passed a CircleCI-shaped debug build and scripts/swift_warning_budget.py with 166 warnings across 75 buckets, under the 225/110 budget.

Notes:

  • The optional Python CLI harness now skips when a normal launched app cannot create an unhosted workspace; the exact bad state was reproduced manually from the real caller workspace.
  • Cloud CUA video proof is blocked: cua-ssh doctor passes SSH, visible windows, and screen-recording preflight, but Sky get_app_state fails with Computer Use server error -10005: cgWindowNotFound after setup/reinstall.

Note

Medium Risk
Changes terminal runtime startup and socket input delivery paths, including new headless bootstrap windows and queue management; regressions could impact background terminals, focus, or socket automation behavior.

Overview
Fixes offscreen/background terminal helpers by bootstrapping Ghostty runtimes in a hidden, borderless “headless” window when a terminal has startup work (initial command/input) or receives cold socket input before any real UI window is attached.

Refactors terminal socket input sending to return explicit results (sent/queued vs inputQueueFull/surfaceUnavailable/processExited), queue parsed input/key events (including control characters like Return/Tab/Escape/Backspace) up to a strict byte cap (rejecting oversize rather than evicting), and surfaces localized error messages via the socket API (and includes queued in v2 responses).

Updates window/visibility checks across UI and focus paths to use TerminalSurface.uiWindow/isViewInWindow (excluding the headless bootstrap window), adds debug health reporting for headless hosting, and introduces extensive unit + optional Python regression tests covering offscreen startup, cold input queuing, teardown, and overflow behavior.

Reviewed by Cursor Bugbot for commit a96e1ce. Bugbot is set up for automated code reviews on this repo. Configure here.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@vercel

vercel Bot commented May 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 16, 2026 7:30am
cmux-staging Building Building Preview, Comment May 16, 2026 7:30am

@coderabbitai

coderabbitai Bot commented May 16, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Add headless (off-window) terminal startup, change pending socket input to ordered variants with strict byte-capacity rejection, update send APIs to return structured results and queue input when runtime is absent, centralize socket error messages, and add tests and debug inspection helpers.

Changes

Terminal Background Startup and Socket Input Lifecycle

Layer / File(s) Summary
Data model, localization, and centralized messages
Resources/Localizable.xcstrings, Sources/GhosttyTerminalView.swift (4405–4454), Sources/TerminalController.swift (84–105)
Add two socket.* localization entries and centralized terminal socket error strings; PendingKeyEvent exposes queued byte cost and base pending-input variants are introduced.
Headless startup window creation, release, and reconciliation
Sources/GhosttyTerminalView.swift (4630–4735, 5160, 5184–5203, 5243–5245, 6306–6319, 6757–6788)
TerminalSurface adds headlessStartupWindow; surfaces with startup work may create a hidden headless window to bootstrap Ghostty before attachment; helpers ensure/release/reconcile headless window; headless window released on reuse/attachment and on deinit.
forceRefresh and post-creation integration
Sources/GhosttyTerminalView.swift (5564–5714)
forceRefresh now gates to main thread, revalidates live surface via guarded access, reasserts display id before refresh, and pending-input flush is scheduled inside MainActor.assumeIsolated after runtime creation.
Pending socket input queue and flush semantics
Sources/GhosttyTerminalView.swift (4405–4454, 6136–6189, 6244–6263)
PendingSocketInput variants (pasteText/inputText/key) and byte-cost accounting; enqueue now rejects inputs that would exceed max bytes instead of evicting; flush writes text chunks and emits synthesized key events via live surface; DEBUG inspection helpers added.
Control-plane socket APIs and parsing
Sources/GhosttyTerminalView.swift (5766–5913, 5948–5985)
sendText/sendInput/sendNamedKey signatures changed to return Bool/Result enums; inputs parsed into ordered text/key events, queued when runtime is missing, and flushed when live; liveSurfaceForSocketWrite wrapper and unified requestBackgroundSurfaceStartIfNeeded introduced.
TerminalController V2 and legacy socket command mapping
Sources/TerminalController.swift (6862–6937, 15304–15611)
V2 handlers send via sendInputResult/sendNamedKey and map send-result enums to standardized socket responses; legacy sendInput/sendKey paths reworked to use new APIs and return centralized error strings; removed some surface-resolution/wait helpers and low-level Ghostty injection helpers.
Workspace background-start call-site updates
Sources/Workspace.swift (1215, 8468)
Call-sites now invoke requestBackgroundSurfaceStartIfNeeded() without allowOffWindow parameter to align with unified headless/background start semantics.
Unit and integration tests / Debug helpers
cmuxTests/TerminalAndGhosttyTests.swift (941–1072, 4583), tests_v2/test_cli_background_terminal_helpers_start_pty.py (1–223)
Add TerminalOffscreenStartupTests for headless startup and cold input semantics; remove obsolete socketTextChunks tests; add Python CLI regression test exercising background helper PTY creation and helper-surface send/read validation; expose DEBUG helpers for runtime creation attempts, headless-window presence, and pending queue state.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

"I tap the quiet headless pane,
bytes line up like carrots in a lane,
cold sockets warm and then they sing,
keys hop in — the startup spring. 🐇"


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (4 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Concurrency ❌ Error Introduces 6 new DispatchSemaphore+Task blocking patterns in TerminalController.swift, violating swift-concurrency-modernization.md rules against legacy async synchronization. Remove DispatchSemaphore blocking in socketWorkerV2Response, v2VmCall, v2FeedbackSubmit. Refactor to use modern async/await patterns or redesign socket-to-async bridge.
Cmux Swift File And Package Boundaries ❌ Error Adds 397 lines to GhosttyTerminalView.swift (>800 existing), exceeding 250-line limit. Focused #4228 fix but violates strict boundary enforcement despite good consolidation. Extract socket enums/parsing to package, split TerminalSurface cold-input into separate file, or document architectural exception for lifecycle-coupled fix.
Cmux Architecture Rethink ❌ Error Uses DispatchQueue.main.async deferral and mutable backgroundSurfaceStartQueued flag—timing repairs for offscreen startup races, violating swift-architectural-rethink rules. Remove asyncAfter deferral and re-entry flag. Establish single source of truth for surface-ready state. Make requestBackgroundSurfaceStartIfNeeded return state synchronously instead of polled/deferred checks.
Cmux Swift Auxiliary Window Close Shortcuts ❌ Error PR introduces new NSWindow (headlessStartupWindow) without cmux.* identifier. While hidden/internal, it's not in the lint script's documented IGNORED_IDENTIFIERS list. Add "cmux.headless" to IGNORED_IDENTIFIERS in scripts/lint_auxiliary_window_close_shortcuts.py as documented internal bootstrap window, or assign/register identifier if it should own close shortcuts.
Docstring Coverage ⚠️ Warning Docstring coverage is 5.88% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (11 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main objective: fixing offscreen terminal helper PTY startup, which is the primary change across the changeset.
Linked Issues check ✅ Passed The PR addresses all key objectives from issue #4228: initializing terminal runtimes via headless window [GhosttyTerminalView.swift], queuing cold socket input with queue limits [GhosttyTerminalView.swift, TerminalController.swift], returning meaningful errors [Localizable.xcstrings, TerminalController.swift], and adding regression coverage [TerminalAndGhosttyTests.swift, test_cli_background_terminal_helpers_start_pty.py].
Out of Scope Changes check ✅ Passed All changes are directly aligned with fixing offscreen terminal helper PTY startup: localization entries for error messages, headless window startup infrastructure, socket input queuing, error handling, and related test coverage. No unrelated changes detected.
Cmux Swift Actor Isolation ✅ Passed No Swift 6 actor isolation violations. New enums are value types. Sendable types use DispatchQueue protection. Private structs in @MainActor class permitted. All methods properly annotated.
Cmux Swift Blocking Runtime ✅ Passed No new blocking synchronization introduced. New methods use DispatchQueue.main.async (non-blocking). Pre-existing v2MainSync infrastructure unchanged.
Cmux No Hacky Sleeps ✅ Passed The only non-Swift code added is a test-only Python file with deterministic polling sleep in a deadline loop. This is explicitly allowed per the rule. No production runtime code violations found.
Cmux Swift @Concurrent ✅ Passed All public methods properly @MainActor annotated. No async functions, @concurrent misuse, or invalid actor combinations detected.
Cmux Swift Logging ✅ Passed No logging violations detected. Zero new print/NSLog/debugPrint/dump in production Swift code. No ad hoc file logging or MainActor-coupled Logger declarations.
Cmux User-Facing Error Privacy ✅ Passed New user-facing error messages comply with privacy rules—no vendor names, internal details, environment variables, or credentials exposed.
Cmux Swiftui State Layout ✅ Passed No SwiftUI state layout violations. No new @Published/@observable patterns. Changes only to existing ObservableObjects' methods/enums. No GeometryReader, lazy/list, or render-time mutation issues.
Description check ✅ Passed The PR description is comprehensive, covering what changed, why, reproduction steps, test results, and notes on limitations. It provides detailed context with issue links, commit references, and bundle paths.
✨ 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 issue-4228-terminal-helper-surfaces-no-pty

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 and usage tips.

Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/GhosttyTerminalView.swift Outdated
@greptile-apps

greptile-apps Bot commented May 16, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes background/offscreen terminal helpers failing to spawn a PTY by bootstrapping Ghostty runtime creation through a hidden headless NSWindow when a surface has startup work or receives cold socket input before a real window is attached. It also hardens socket-driven input by replacing boolean send APIs with typed InputSendResult/NamedKeySendResult enums, rejecting oversized queue enqueues instead of evicting older input, and returning localized errors for process_exited, surface_unavailable, and input_queue_full.

  • Headless bootstrap: TerminalSurface now creates a hidden NSWindow (alpha 0, ignored for mouse/focus/window-menu) during init when startup work is detected; uiWindow/isViewInWindow filter out this window so health checks, focus, and visibility logic continue to treat the surface as unhosted until a real portal attaches.
  • Typed send results: sendInputResult, sendNamedKey, and sendText return structured enums rather than booleans; all seven socket handlers in TerminalController are updated to switch on the result and return distinct error codes with localized copy.
  • Regression coverage: Nine new TerminalOffscreenStartupTests cases cover cold-input queuing, queue overflow rejection, backspace key event handling, lifecycle teardown, and headless-window visibility semantics.

Confidence Score: 4/5

Safe to merge; the one open gap is only reachable during initial PTY startup when a process exit at that exact moment is exceedingly unlikely in practice.

The core headless-bootstrap and typed-result changes are well-covered by nine new unit tests. Actor-isolation corrections flagged in earlier rounds are applied throughout. The only non-trivial asymmetry is that flushPendingSocketInputIfNeeded does not call ghostty_surface_process_exited, while every live-send path does — a real consistency gap but not an immediately actionable defect.

Sources/GhosttyTerminalView.swift — specifically the flushPendingSocketInputIfNeeded path, which should mirror the process-exit guard present on all live-send paths.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Core change: adds headless NSWindow bootstrap, InputSendResult/NamedKeySendResult typed returns, uiWindow/isViewInWindow to hide bootstrap window from visibility checks; previously-flagged @mainactor gaps are corrected; flushPendingSocketInputIfNeeded skips the process-exited guard that live-send paths enforce.
Sources/TerminalController.swift Replaces bool/surface-based send calls with InputSendResult/NamedKeySendResult switch blocks; removes legacy waitForTerminalSurface polling helper; adds localized error messages for all three new error codes.
Sources/Panels/TerminalPanel.swift focus() now gates ensureFocus on uiWindow (not hostedView.window) to prevent focus attempts on headless bootstrap window.
Sources/Workspace.swift Call sites updated from hostedView.window to surface.isViewInWindow/uiWindow; allowOffWindow:true removed in favour of universal headless bootstrap.
cmuxTests/TerminalAndGhosttyTests.swift Adds 9 regression tests covering offscreen startup, cold-input queuing, control-key handling, queue overflow rejection, lifecycle teardown, and headless-window semantics.

Reviews (14): Last reviewed commit: "fix: ignore headless window in force ref..." | Re-trigger Greptile

Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment thread Sources/GhosttyTerminalView.swift Outdated
coderabbitai[bot]
coderabbitai Bot previously requested changes May 16, 2026

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Sources/GhosttyTerminalView.swift (2)

4627-4721: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract the headless-startup and socket-input queue logic into dedicated types.

This PR adds another large block of AppKit bootstrap/window management and socket-input lifecycle code to a production Swift file that is already far past the repo budget. Keeping this here also deepens the mix of rendering, state ownership, platform bridge, and socket protocol logic in one place.

As per coding guidelines "Do not add more than 250 lines to an existing production Swift file that is already over 800 lines" and "Do not mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one Swift file."

Also applies to: 5727-6156

🤖 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 `@Sources/GhosttyTerminalView.swift` around lines 4627 - 4721, The
headless-startup and socket-input queue logic should be moved out of
GhosttyTerminalView into dedicated helper types: create a HeadlessWindowManager
type encapsulating headlessStartupWindow,
ensureHeadlessStartupWindowIfNeeded(reason:),
releaseHeadlessStartupWindowIfNeeded(for:), and
startRuntimeUsingHeadlessWindowIfNeeded(reason:) (use
GhosttyTerminalView.hostedView, surfaceView, id, and
allowsRuntimeSurfaceCreation() only via well-defined APIs), and create a
SocketInputQueue type for the socket/input lifecycle code referenced around
lines 5727-6156; update GhosttyTerminalView methods (updateWorkspaceId,
reconcileAttachedWindowIfNeeded, and any callers of the moved methods) to
delegate to these new types, preserve all existing behaviors (main-thread
dispatch, window creation flags, debug logs using id.uuidString.prefix(8), and
calling hostedView.attachSurface(self)), and add unit-testable interfaces so UI
code no longer mixes socket or lifecycle/state ownership logic in this file.

5648-5659: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep forceRefresh on the live-surface path.

beginPortalCloseLifecycle leaves surface non-nil while the lifecycle is .closing. With this guard, forceRefresh can still reach ghostty_surface_set_display_id / ghostty_surface_refresh during teardown instead of bailing through the existing live-surface quarantine path.

Suggested fix
-        guard let view = attachedView,
-              surface != nil,
+        guard let view = attachedView,
               view.window != nil,
               view.bounds.width > 0,
               view.bounds.height > 0 else {
             return
         }
 `#if` DEBUG
         recordDebugForceRefresh()
 `#endif`
-        guard let currentSurface = self.surface else { return }
+        guard let currentSurface = liveSurfaceForGhosttyAccess(
+            reason: "forceRefresh.\(reason)"
+        ) else { return }
🤖 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 `@Sources/GhosttyTerminalView.swift` around lines 5648 - 5659, The guard that
returns early when view/window/bounds are invalid prevents forceRefresh from
reaching the live surface during beginPortalCloseLifecycle; update the guard
logic in the live-surface path so that when self.surface is non-nil and the
lifecycle is .closing (as set by beginPortalCloseLifecycle) you do not bail out
if a pending forceRefresh should run—allow the code path that calls
ghostty_surface_set_display_id / ghostty_surface_refresh to proceed for
forceRefresh. Locate the guard around
attachedView/surface/view.window/view.bounds and modify it to short-circuit only
when there is no surface or when lifecycle is not .closing (or when forceRefresh
is false), ensuring forceRefresh still executes against the live surface during
teardown.
🤖 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 `@Sources/TerminalController.swift`:
- Around line 15602-15619: The legacy send-key paths
(terminalPanel.surface.sendNamedKey(...) handling and the similar branch later)
need to trigger a UI refresh when the surface accepted the key; update the code
paths that currently set success = true for the .sent case to also call the same
refresh used by v2SurfaceSendKey (invoke forceRefresh on the relevant
TerminalSurface/terminalPanel.surface) immediately after a .sent result so a
subsequent read-screen sees the updated frame; apply the same change in both the
sendNamedKey handling blocks (the earlier block around
terminalPanel.surface.sendNamedKey and the later similar block) and ensure you
only call forceRefresh when the surface is live/accepted (i.e., in the .sent
branch).

---

Outside diff comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 4627-4721: The headless-startup and socket-input queue logic
should be moved out of GhosttyTerminalView into dedicated helper types: create a
HeadlessWindowManager type encapsulating headlessStartupWindow,
ensureHeadlessStartupWindowIfNeeded(reason:),
releaseHeadlessStartupWindowIfNeeded(for:), and
startRuntimeUsingHeadlessWindowIfNeeded(reason:) (use
GhosttyTerminalView.hostedView, surfaceView, id, and
allowsRuntimeSurfaceCreation() only via well-defined APIs), and create a
SocketInputQueue type for the socket/input lifecycle code referenced around
lines 5727-6156; update GhosttyTerminalView methods (updateWorkspaceId,
reconcileAttachedWindowIfNeeded, and any callers of the moved methods) to
delegate to these new types, preserve all existing behaviors (main-thread
dispatch, window creation flags, debug logs using id.uuidString.prefix(8), and
calling hostedView.attachSurface(self)), and add unit-testable interfaces so UI
code no longer mixes socket or lifecycle/state ownership logic in this file.
- Around line 5648-5659: The guard that returns early when view/window/bounds
are invalid prevents forceRefresh from reaching the live surface during
beginPortalCloseLifecycle; update the guard logic in the live-surface path so
that when self.surface is non-nil and the lifecycle is .closing (as set by
beginPortalCloseLifecycle) you do not bail out if a pending forceRefresh should
run—allow the code path that calls ghostty_surface_set_display_id /
ghostty_surface_refresh to proceed for forceRefresh. Locate the guard around
attachedView/surface/view.window/view.bounds and modify it to short-circuit only
when there is no surface or when lifecycle is not .closing (or when forceRefresh
is false), ensuring forceRefresh still executes against the live surface during
teardown.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7e56f187-d512-4335-83e7-090816fb7e11

📥 Commits

Reviewing files that changed from the base of the PR and between a4aa612 and 1bea9cc.

📒 Files selected for processing (5)
  • Resources/Localizable.xcstrings
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalController.swift
  • cmuxTests/TerminalAndGhosttyTests.swift
  • tests_v2/test_cli_background_terminal_helpers_start_pty.py

Comment thread Sources/TerminalController.swift Outdated

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/TerminalController.swift">

<violation number="1" location="Sources/TerminalController.swift:15607">
P2: Force a refresh after successful live key sends in the legacy socket key paths; otherwise an immediate `read-screen` can return stale terminal content even though the command returned OK.</violation>
</file>

<file name="Sources/GhosttyTerminalView.swift">

<violation number="1" location="Sources/GhosttyTerminalView.swift:6260">
P1: Move headless window teardown onto the main thread; `NSWindow` mutations in `deinit` are not thread-safe when the final release happens off-main.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment thread Sources/TerminalController.swift
@lawrencecchen
lawrencecchen force-pushed the issue-4228-terminal-helper-surfaces-no-pty branch 2 times, most recently from f48f3b3 to a17258d Compare May 16, 2026 01:39
@lawrencecchen
lawrencecchen force-pushed the issue-4228-terminal-helper-surfaces-no-pty branch from a17258d to d13a450 Compare May 16, 2026 01:42
Comment thread Sources/GhosttyTerminalView.swift Outdated
@lawrencecchen
lawrencecchen force-pushed the issue-4228-terminal-helper-surfaces-no-pty branch from d13a450 to 37db1dd Compare May 16, 2026 01:53
Comment thread Sources/GhosttyTerminalView.swift
coderabbitai[bot]
coderabbitai Bot previously requested changes May 16, 2026

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Sources/GhosttyTerminalView.swift (2)

5635-5692: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Keep forceRefresh() allocation-free on the hot path.

This now builds viewState strings in all builds, and the interpolated "forceRefresh.\(reason)" / "forceRefresh.refresh.\(reason)" tokens also allocate on every refresh. That adds avoidable work to a method that already runs on keystrokes.

💡 Suggested tightening
 func forceRefresh(reason: String = "unspecified") {
     if !Thread.isMainThread {
         DispatchQueue.main.async { [weak self] in
             self?.forceRefresh(reason: reason)
         }
         return
     }

-    let hasSurface = surface != nil
-    let viewState: String
-    if let view = attachedView {
-        let inWindow = view.window != nil
-        let bounds = view.bounds
-        let metalOK = (view.layer as? CAMetalLayer) != nil
-        viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)"
-    } else {
-        viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)"
-    }
     `#if` DEBUG
+    let hasSurface = surface != nil
+    let viewState: String
+    if let view = attachedView {
+        let inWindow = view.window != nil
+        let bounds = view.bounds
+        let metalOK = (view.layer as? CAMetalLayer) != nil
+        viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)"
+    } else {
+        viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)"
+    }
     cmuxDebugLog("forceRefresh: \(id) reason=\(reason) \(viewState)")
     `#endif`
@@
     let displayID = (view.window?.screen ?? NSScreen.main)?.displayID
     let hasLiveSurface = MainActor.assumeIsolated {
-        guard let currentSurface = liveSurfaceForGhosttyAccess(reason: "forceRefresh.\(reason)") else {
+        guard let currentSurface = liveSurfaceForGhosttyAccess(reason: "forceRefresh") else {
             return false
         }
@@
     view.forceRefreshSurface()
     MainActor.assumeIsolated {
-        guard let surface = liveSurfaceForGhosttyAccess(reason: "forceRefresh.refresh.\(reason)") else {
+        guard let surface = liveSurfaceForGhosttyAccess(reason: "forceRefresh.refresh") else {
             return
         }
         ghostty_surface_refresh(surface)
     }
 }

As per coding guidelines: In TerminalSurface.forceRefresh() in GhosttyTerminalView.swift, do not add allocations, file I/O, or formatting as it's called on every keystroke.

🤖 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 `@Sources/GhosttyTerminalView.swift` around lines 5635 - 5692, The method
forceRefresh builds strings every call (viewState and the interpolated reasons
passed to liveSurfaceForGhosttyAccess), causing allocations on the hot path; fix
by moving construction of viewState and any reason interpolation inside `#if`
DEBUG so they are only created in debug builds, and in release use constant
literals (e.g. "forceRefresh" and "forceRefresh.refresh") when calling
liveSurfaceForGhosttyAccess/ghostty_surface_refresh; update the code around
viewState, the displayID block that calls liveSurfaceForGhosttyAccess(reason:
...), and the subsequent MainActor.assumeIsolated call to use the debug-only
interpolated strings and release-only constants to avoid per-keystroke
allocations.

4388-6291: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract the startup/queueing lifecycle out of this file.

This PR adds another large block of runtime bootstrap, socket-queue, and lifecycle code to a production file that is already far beyond the repo’s size budget. It also pushes more subprocess/socket protocol behavior into the same file as AppKit rendering and portal-host code, which makes future changes harder to reason about safely.

As per coding guidelines: Do not add more than 250 lines to an existing production Swift file that is already over 800 lines and Do not mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one Swift file.

🤖 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 `@Sources/GhosttyTerminalView.swift` around lines 4388 - 6291, The file mixes
UI/portal code with a large startup/socket-queue lifecycle; extract the non-UI
runtime/bootstrap and socket queue logic into a new helper (e.g.,
TerminalRuntimeLifecycle or TerminalSocketQueue) and keep TerminalSurface as the
UI/portal owner that delegates lifecycle and queue operations. Move types and
functions like PendingKeyEvent, PendingSocketInput, ParsedSocketInput,
parsedSocketInputEvents(for:), enqueuePendingSocketInputs(_:),
enqueuePendingSocketInput(_:), flushPendingSocketInputIfNeeded(),
requestBackgroundSurfaceStartIfNeeded(),
startRuntimeUsingHeadlessWindowIfNeeded(reason:),
ensureHeadlessStartupWindowIfNeeded(reason:), createSurface(for:),
writeTextData(_:to:), sendInput(_:to:), sendText(_:), sendInputResult(_:),
sendNamedKey(_:), liveSurfaceForSocketWrite(reason:), and any supporting
constants/locks into the new file/class; expose a minimal MainActor-isolated API
(enqueue, flush, requestStart, createIfNeeded, liveSurface accessor) that
TerminalSurface calls, preserve existing behaviors/annotations (MainActor, debug
logs, TerminalSurfaceRegistry interactions) and update all internal calls in
TerminalSurface to delegate to the new helper while keeping UI-specific pieces
(attachedView, hostedView, view lifecycle, display id, forceRefresh, focus,
portal lease/state) in TerminalSurface. Ensure tests and debug-only helpers are
updated to use the new helper and keep runtime surface ownership and callback
context management consistent when moving createSurface-related code.
🤖 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 `@Sources/GhosttyTerminalView.swift`:
- Around line 5744-5804: The current cold-path enqueues input even after the
surface lifecycle has closed (so requestBackgroundSurfaceStartIfNeeded() is a
no-op), leaving input stranded; update sendText(_:), sendInputResult(_:), and
sendNamedKey(_:) to first check the surface lifecycle/startability before
enqueuing: call whatever predicate is used by
requestBackgroundSurfaceStartIfNeeded (or add a helper like
canStartBackgroundSurface / isSurfaceLifecycleActive) and if it indicates the
surface cannot be started, immediately reject the input (return false for
sendText, .inputQueueFull for sendInputResult, and .inputQueueFull or
.surfaceUnavailable for sendNamedKey as appropriate) instead of
enqueuePendingSocketInput; keep existing behavior when the predicate allows
background start. Ensure references to enqueuePendingSocketInput,
requestBackgroundSurfaceStartIfNeeded, sendText, sendInputResult, and
sendNamedKey are updated accordingly.

---

Outside diff comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 5635-5692: The method forceRefresh builds strings every call
(viewState and the interpolated reasons passed to liveSurfaceForGhosttyAccess),
causing allocations on the hot path; fix by moving construction of viewState and
any reason interpolation inside `#if` DEBUG so they are only created in debug
builds, and in release use constant literals (e.g. "forceRefresh" and
"forceRefresh.refresh") when calling
liveSurfaceForGhosttyAccess/ghostty_surface_refresh; update the code around
viewState, the displayID block that calls liveSurfaceForGhosttyAccess(reason:
...), and the subsequent MainActor.assumeIsolated call to use the debug-only
interpolated strings and release-only constants to avoid per-keystroke
allocations.
- Around line 4388-6291: The file mixes UI/portal code with a large
startup/socket-queue lifecycle; extract the non-UI runtime/bootstrap and socket
queue logic into a new helper (e.g., TerminalRuntimeLifecycle or
TerminalSocketQueue) and keep TerminalSurface as the UI/portal owner that
delegates lifecycle and queue operations. Move types and functions like
PendingKeyEvent, PendingSocketInput, ParsedSocketInput,
parsedSocketInputEvents(for:), enqueuePendingSocketInputs(_:),
enqueuePendingSocketInput(_:), flushPendingSocketInputIfNeeded(),
requestBackgroundSurfaceStartIfNeeded(),
startRuntimeUsingHeadlessWindowIfNeeded(reason:),
ensureHeadlessStartupWindowIfNeeded(reason:), createSurface(for:),
writeTextData(_:to:), sendInput(_:to:), sendText(_:), sendInputResult(_:),
sendNamedKey(_:), liveSurfaceForSocketWrite(reason:), and any supporting
constants/locks into the new file/class; expose a minimal MainActor-isolated API
(enqueue, flush, requestStart, createIfNeeded, liveSurface accessor) that
TerminalSurface calls, preserve existing behaviors/annotations (MainActor, debug
logs, TerminalSurfaceRegistry interactions) and update all internal calls in
TerminalSurface to delegate to the new helper while keeping UI-specific pieces
(attachedView, hostedView, view lifecycle, display id, forceRefresh, focus,
portal lease/state) in TerminalSurface. Ensure tests and debug-only helpers are
updated to use the new helper and keep runtime surface ownership and callback
context management consistent when moving createSurface-related code.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 842ccfb7-0368-42be-8b5b-34389e320860

📥 Commits

Reviewing files that changed from the base of the PR and between 1bea9cc and 1faa11e.

📒 Files selected for processing (6)
  • Resources/Localizable.xcstrings
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/TerminalAndGhosttyTests.swift
  • tests_v2/test_cli_background_terminal_helpers_start_pty.py

Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/TerminalController.swift Outdated
Comment thread Sources/TerminalController.swift
coderabbitai[bot]
coderabbitai Bot previously requested changes May 16, 2026

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

🤖 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 `@cmuxTests/TerminalAndGhosttyTests.swift`:
- Around line 1045-1049: The test currently overwrites
TerminalController.shared's active tab manager and unconditionally restores nil;
instead capture the previous manager into a local (e.g., let previousManager =
TerminalController.shared.activeTabManager or similar) before creating the new
TabManager, call TerminalController.shared.setActiveTabManager(manager), and in
the defer restore the original value by calling
TerminalController.shared.setActiveTabManager(previousManager) so the exact
prior state is reinstated; reference the TabManager initializer and
TerminalController.shared.setActiveTabManager when making the change.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 5758-5818: The guard that calls
ghostty_surface_process_exited(runtimeSurface) can dereference a stale surface
pointer; reorder checks in sendText(_:), sendNamedKey(_:), and
sendInputResult(_:) so you first obtain a liveSurface via
liveSurfaceForSocketWrite(reason:...), and only then check runtimeSurface state
(or avoid calling ghostty_surface_process_exited if liveSurface is nil).
Concretely: in sendText, sendNamedKey, and sendInputResult, move the
liveSurfaceForSocketWrite(...) guard ahead of the
ghostty_surface_process_exited(...) guard (or skip the process_exited check when
liveSurface is already nil), ensuring you never call
ghostty_surface_process_exited on a possibly torn-down surface pointer.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 81b92702-21e5-4fdc-8004-84cf9c701d84

📥 Commits

Reviewing files that changed from the base of the PR and between 1faa11e and b08ab60.

📒 Files selected for processing (6)
  • Resources/Localizable.xcstrings
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/TerminalAndGhosttyTests.swift
  • tests_v2/test_cli_background_terminal_helpers_start_pty.py

Comment thread cmuxTests/TerminalAndGhosttyTests.swift
Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/TerminalController.swift Outdated
coderabbitai[bot]
coderabbitai Bot previously requested changes May 16, 2026

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/GhosttyTerminalView.swift (1)

4401-4700: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract the pending-input and bootstrap-window logic out of this file.

This PR adds more terminal lifecycle, socket-input parsing, queue accounting, and AppKit bootstrap-window management to a file that is already far beyond the repo’s size budget. Please move this into dedicated helper types/files instead of deepening GhosttyTerminalView.swift’s mixed responsibilities.

As per coding guidelines "Do not add more than 250 lines to an existing production Swift file that is already over 800 lines..." and "Do not mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one Swift file".

Also applies to: 5756-6191, 6234-6308

🤖 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 `@Sources/GhosttyTerminalView.swift` around lines 4401 - 4700, The file is too
large and mixes responsibilities; extract the pending-input queue and
bootstrap-window logic into separate helper types/files: move the
PendingKeyEvent, PendingSocketInput, ParsedSocketInput, Pending queue state
(pendingSocketInputQueue, pendingSocketInputBytes, maxPendingSocketInputBytes)
and their queue-management logic into a new SocketInputQueue (or similar) type,
and move startRuntimeUsingHeadlessWindowIfNeeded,
ensureHeadlessStartupWindowIfNeeded, headlessStartupWindow and any headless
bootstrap window management into a new HeadlessWindowManager type; update
GhosttyTerminalView to hold instances of these helpers, forward calls and state
(e.g. rename/keep methods like startRuntimeUsingHeadlessWindowIfNeeded ->
HeadlessWindowManager.startIfNeeded) and preserve access control, main-thread
assertions, and existing references to surfaceView, hostedView, tabId, and id so
behavior is 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 `@Sources/GhosttyTerminalView.swift`:
- Around line 4716-4724: reconcileAttachedWindowIfNeeded currently uses the raw
surface pointer and may call ghostty_surface_set_display_id on a stale surface;
update it to call liveSurfaceForGhosttyAccess(...) (same helper used by
forceRefresh and socket write paths) to revalidate and obtain a live surface
before calling ghostty_surface_set_display_id, i.e. replace the guard-let that
binds surface with a guard-let that binds s from
liveSurfaceForGhosttyAccess(self) (or the appropriate receiver) and then call
ghostty_surface_set_display_id(s, displayID); keep
releaseHeadlessStartupWindowIfNeeded(for:) and the screen/displayID checks
as-is.

---

Outside diff comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 4401-4700: The file is too large and mixes responsibilities;
extract the pending-input queue and bootstrap-window logic into separate helper
types/files: move the PendingKeyEvent, PendingSocketInput, ParsedSocketInput,
Pending queue state (pendingSocketInputQueue, pendingSocketInputBytes,
maxPendingSocketInputBytes) and their queue-management logic into a new
SocketInputQueue (or similar) type, and move
startRuntimeUsingHeadlessWindowIfNeeded, ensureHeadlessStartupWindowIfNeeded,
headlessStartupWindow and any headless bootstrap window management into a new
HeadlessWindowManager type; update GhosttyTerminalView to hold instances of
these helpers, forward calls and state (e.g. rename/keep methods like
startRuntimeUsingHeadlessWindowIfNeeded -> HeadlessWindowManager.startIfNeeded)
and preserve access control, main-thread assertions, and existing references to
surfaceView, hostedView, tabId, and id so behavior is 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bf8b8bda-71af-4b04-a658-12facc0add59

📥 Commits

Reviewing files that changed from the base of the PR and between b08ab60 and 1111ddd.

📒 Files selected for processing (6)
  • Resources/Localizable.xcstrings
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/TerminalAndGhosttyTests.swift
  • tests_v2/test_cli_background_terminal_helpers_start_pty.py

Comment thread Sources/GhosttyTerminalView.swift
@lawrencecchen
lawrencecchen force-pushed the issue-4228-terminal-helper-surfaces-no-pty branch from 1111ddd to 7419925 Compare May 16, 2026 03:27
@lawrencecchen
lawrencecchen dismissed stale reviews from coderabbitai[bot], coderabbitai[bot], coderabbitai[bot], and coderabbitai[bot] May 16, 2026 05:02

Resolved in follow-up commits; latest hosted checks and reviewer checks are green.

Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/GhosttyTerminalView.swift

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 72707b2. Configure here.

Comment thread Sources/GhosttyTerminalView.swift
@MainActor
private func scheduleHeadlessRuntimeStartIfNeeded(reason: String) {
startRuntimeUsingHeadlessWindowIfNeeded(reason: reason)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant single-line wrapper adds unnecessary indirection

Low Severity

scheduleHeadlessRuntimeStartIfNeeded is a one-line wrapper that simply calls startRuntimeUsingHeadlessWindowIfNeeded with the same parameters and identical annotations (@MainActor, private). It adds an unnecessary layer of indirection without any scheduling, debouncing, or gating logic that would justify its existence. Callers could invoke startRuntimeUsingHeadlessWindowIfNeeded directly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 72707b2. Configure here.

This branch was successfully deployed

1 active deployment
Preview – cmux — a96e1ced Deployed May 16, 2026 by vercel[bot]
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.

cmux CLI can create terminal helper surfaces with no PTY

1 participant