Skip to content

Wait for shell prompt before typing 'cmux welcome' (fixes #1900) - #4682

Closed
juliankmazo wants to merge 8 commits into
manaflow-ai:mainfrom
juliankmazo:fix-welcome-wait-for-prompt
Closed

juliankmazo wants to merge 8 commits into
manaflow-ai:mainfrom
juliankmazo:fix-welcome-wait-for-prompt

Conversation

@juliankmazo

@juliankmazo juliankmazo commented May 24, 2026 •

Copy link
Copy Markdown

Summary

The first-launch welcome banner is injected by typing cmux welcome\n into the new workspace's PTY 0.5s after the Ghostty surface becomes ready. That blind delay races shell init. With oh-my-zsh's auto-update prompt reading stdin during sourcing, the c of cmux welcome is consumed by the Would you like to update? [Y/n] prompt, and the rest of the line lands on the real shell prompt as mux welcome, leaving every fresh oh-my-zsh user with:

[oh-my-zsh] Would you like to update? [Y/n]
[oh-my-zsh] You can update manually by running `omz update`
☁  ~  mux welcome
zsh: command not found: mux

This is the canonical instance of the bigger pattern called out in #1900: cmux types commands into a shell that isn't actually at a prompt yet.

Approach

Workspace owns the wait-for-prompt state machine. cmux's shell integration already drives panelShellActivityStates[panelId] through .promptIdle / .commandRunning via Workspace.updatePanelShellActivityState; this PR wires that into a single owner:

  • Workspace.updatePanelShellActivityState posts .panelShellActivityStateDidChange on every real state change (deduplicated; userInfo carries workspaceId, panelId, state).
  • Workspace.sendTextOnNextPromptIdle(_:beforeSend:) is the single coordination point. It checks the synchronous fast path, otherwise registers a [weak self] observer that fires beforeSend + sendText exactly once when .promptIdle is first observed for the focused panel.
  • Outstanding observer tokens are tracked on Workspace and swept in deinit, so workspaces without shell integration installed (or workspaces closed before integration fires) do not leak their observer block.
  • AppDelegate.sendWelcomeCommandWhenReady is a one-line delegate to the workspace.
  • TabManager's auto-welcome branch calls the workspace directly. The old TabManager.sendWelcomeWhenReady (with its blind asyncAfter(0.5)) is deleted.

No timing fallback. If cmux shell integration is not installed the panel never reaches .promptIdle and the welcome banner is skipped. That's the architectural invariant the project's swift-blocking-runtime.md rule asks for — any time-based fallback would still race shell init, which is the bug we're fixing. Users without shell integration get a clean prompt instead of mux welcome / command not found: mux; the better fix for them is to surface cmux hooks setup more prominently (#4515) rather than to keep typing into a shell whose state we can't verify.

Net change

 Sources/AppDelegate.swift                       |  18 ++---
 Sources/TabManager.swift                        |  92 +---------------------
 Sources/TerminalController.swift                |   9 +++
 Sources/Workspace.swift                         |  74 ++++++++++++++++++++
 cmuxTests/PanelShellActivityNotificationTests.swift |  126 ++++++++++++++
 cmux.xcodeproj/project.pbxproj                  |   4 +
 6 files changed, 219 insertions(+), 104 deletions(-)

More code is deleted than added. No new DispatchQueue.asyncAfter, no new NSLog, no duplicate coordination logic.

Regression test

Per CLAUDE.md's two-commit regression policy, this PR is structured as:

  1. Add failing test — PanelShellActivityNotificationTests asserting the new notification is posted with the expected userInfo and not posted for duplicate state. CI should go red on this commit alone (no post site exists).
  2. Apply the fix — production change in Workspace.swift, AppDelegate.swift, TabManager.swift. CI should go green.

Later commits in the branch address reviewer feedback (single-owner refactor, observer leak fix, synchronous test delivery to remove flake risk).

Tests cover:

  • Notification fires on real state transitions with correct userInfo (testStateChangePostsNotificationWithWorkspaceAndPanelIds).
  • Notification does NOT fire on duplicate state (testDuplicateStateDoesNotPostNotification).
  • sendTextOnNextPromptIdle waits, fires exactly once on first .promptIdle, and is one-shot through later state bounces (testSendTextOnNextPromptIdleFiresExactlyOnceAtFirstPromptIdle).
  • The pending observer does not retain its Workspace when .promptIdle never fires (testSendTextOnNextPromptIdleDoesNotLeakWorkspaceWhenPromptIdleNeverFires).

./scripts/lint-pbxproj-test-wiring.sh passes locally.

Test plan

  • CI: cmuxTests runs PanelShellActivityNotificationTests and the rest of the suite passes
  • Manual: launch a fresh-install cmux with cmux shell integration set up (cmux hooks setup). Banner appears under the prompt, no command not found: mux, even with oh-my-zsh's auto-update prompt waiting on stdin
  • Manual: launch with ~/.zshrc that does not run oh-my-zsh and no shell integration — welcome banner is skipped (intentional; better than typing into an unverified prompt)
  • Manual: open many workspaces in succession without shell integration — no Workspace retain cycles or growing observer count

Notes for reviewers

  • The single-owner refactor (Workspace.sendTextOnNextPromptIdle) was done in response to Greptile's P1 (duplicate coordination logic) and CodeRabbit's architectural-rethink pre-merge check.
  • The decision to skip the welcome for users without shell integration is deliberate: the project rules prohibit timing-based race repair on latency-sensitive paths, and any timing fallback would still hit the same bug it's meant to fix. If a non-timing fallback is preferred (e.g. write the banner content to the terminal as output instead of input, bypassing the PTY input race entirely), happy to follow up — that needs new plumbing through ghostty_surface_*.
  • CodeRabbit's @concurrent finding is a false positive: workspacePullRequestAuthHeaderValue was added by Austin Wang in e39f5e02c8 (April 2026) and is unmodified by this PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Notify when a panel’s shell activity state changes and add a one-shot "send text on next prompt idle" delivery to the focused terminal.
  • Bug Fixes

    • Consolidated welcome sending to the workspace-level mechanism: welcome text is deferred until the terminal is ready and the onboarding-shown flag is set when scheduled.
  • Tests

    • Added tests covering notifications, prompt-idle delivery semantics, one-shot behavior, idempotency, and leak regression.

Review Change Stack

@vercel

vercel Bot commented May 24, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented May 24, 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

Adds Notification.Name.panelShellActivityStateDidChange and PanelShellActivityNotificationKey; posts notifications on actual panel state transitions; adds Workspace.sendTextOnNextPromptIdle(one-shot fast-path + observer cleanup); updates welcome-send call sites; adds tests and project wiring.

Changes

Shell Activity Notifications and Prompt-Idle Waiting

Layer / File(s) Summary
Notification constants and posting
Sources/TerminalController.swift, Sources/Workspace.swift
Adds panelShellActivityStateDidChange and PanelShellActivityNotificationKey; Workspace.updatePanelShellActivityState posts the notification with workspaceId, panelId, and state.
Workspace observer tracking and cleanup
Sources/Workspace.swift
Adds pendingPromptIdleObservers storage and removes stored observers in Workspace.deinit.
Workspace send-on-next-prompt-idle API
Sources/Workspace.swift
Adds @MainActor func sendTextOnNextPromptIdle(_ text: String, beforeSend: (() -> Void)? = nil) with immediate fast-path when focused panel is .promptIdle; otherwise registers a one-shot observer, sends once on first .promptIdle, and unregisters.
Callers switch to prompt-idle API
Sources/TabManager.swift, Sources/AppDelegate.swift
TabManager flips WelcomeSettings.shownKey and calls newWorkspace.sendTextOnNextPromptIdle("cmux welcome\n"); AppDelegate's sendWelcomeCommandWhenReady simplified to call the workspace API.
Tests and project wiring
cmuxTests/PanelShellActivityNotificationTests.swift, cmux.xcodeproj/project.pbxproj
Adds tests asserting notification payload, idempotency, one-shot send semantics, and leak regression; adds project entries so the test file is compiled into the test bundle.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Workspace
  participant NotificationCenter
  participant TerminalPanel

  Caller->>Workspace: sendTextOnNextPromptIdle("cmux welcome\n")
  alt focused panel already .promptIdle
    Workspace->>TerminalPanel: sendText("cmux welcome\n")
  else wait for first .promptIdle
    Workspace->>NotificationCenter: observe panelShellActivityStateDidChange (workspace scope)
    NotificationCenter->>Workspace: notify .promptIdle for focused panel
    Workspace->>TerminalPanel: sendText("cmux welcome\n")
    Workspace->>NotificationCenter: remove observer
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I waited by the prompt so shy and small,
Till idle bells declared the shell’s call.
Then typed a friendly, one-shot line,
No lingering hooks, no memory vine.
Hooray — the welcome landed fine!

🚥 Pre-merge checks | ✅ 16 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the primary change: implementing a wait-for-prompt mechanism before typing the welcome command, which directly addresses the referenced issue #1900.
Description check ✅ Passed The description provides a comprehensive overview covering the problem statement, approach, net changes, regression tests, test plan, and notes for reviewers. It aligns well with the template structure for Summary, Testing, and Checklist sections.
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.
Cmux Swift Actor Isolation ✅ Passed Production code maintains Swift 6 actor isolation: @MainActor Workspace class, isolated methods, Sendable notifications, weak self captures, and deinit cleanup.
Cmux Swift Blocking Runtime ✅ Passed PR removes timing-based asyncAfter and replaces with event-driven NotificationCenter signals in Workspace.sendTextOnNextPromptIdle. No new blocking primitives introduced to production code.
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift code changes and a build config file; rule applies only to TypeScript, JavaScript, shell, and non-Swift runtime scripts.
Cmux Swift Concurrency ✅ Passed New welcome code uses NotificationCenter callbacks with weak self and deinit cleanup, avoiding fire-and-forget Tasks, background queues, or Combine patterns.
Cmux Swift @Concurrent ✅ Passed Workspace @MainActor; sendTextOnNextPromptIdle properly isolated, lightweight synchronous operations, weak captures, proper cleanup. No missing @concurrent or heavy async work without hops.
Cmux Swift File And Package Boundaries ✅ Passed All additions under 250-line threshold for oversized files. Workspace (+101), TerminalController (+18), TabManager (-56). No responsibility mixing; focused bug fix for issue #1900.
Cmux Swift Logging ✅ Passed PR adds no prohibited logging. Only DEBUG-guarded cmuxDebugLog in Workspace.updatePanelShellActivityState logs safe truncated UUIDs and state enums, compliant with swift-logging.md rules.
Cmux User-Facing Error Privacy ✅ Passed No user-facing errors or sensitive data exposed. Changes are internal notification mechanisms, prompt-wait logic, and welcome refactoring without vendor names, credentials, or tokens.
Cmux Full Internationalization ✅ Passed No user-facing text added without localization. New code includes internal notification constants, test cases, and literal CLI command tokens—all within allowed exceptions.
Cmux Swiftui State Layout ✅ Passed No new SwiftUI state patterns introduced. Changes are notification infrastructure and business logic; pendingPromptIdleObservers is private and non-published.
Cmux Architecture Rethink ✅ Passed Replaces timing-based welcome with state-driven Workspace API, proper ownership, deduplication, weak self/deinit cleanup, clear invariant.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR does not add standalone windows; changes are notification infrastructure, one-shot observer logic for shell state, and welcome command timing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps

greptile-apps Bot commented May 24, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the welcome-banner race condition (#1900) by replacing a blind 0.5 s asyncAfter with a proper shell-integration signal: Workspace.sendTextOnNextPromptIdle waits for .promptIdle before typing cmux welcome , so oh-my-zsh's auto-update stdin-read can no longer consume the first character. The old duplicate coordination logic in TabManager.sendWelcomeWhenReady and its timer are deleted, the welcome-flag is set synchronously so users without shell integration don't accumulate observers, and deinit sweeps any outstanding tokens.

  • Core mechanism: updatePanelShellActivityState now posts .panelShellActivityStateDidChange on every real state transition (deduplicated); sendTextOnNextPromptIdle registers a [weak self] one-shot observer that fires beforeSend + sendText exactly once on first .promptIdle, with a synchronous fast path for workspaces that have already reported idle.
  • No timing fallback by design: users without shell integration skip the banner rather than receive text in an unknown shell state; the intent is to surface cmux hooks setup instead.
  • Test coverage: four XCTest cases pin notification deduplication, one-shot firing, and the no-leak-when-promptIdle-never-fires regression.

Confidence Score: 4/5

Safe to merge with one follow-up: updatePanelShellActivityState should be annotated @MainActor to match the invariant the observer's assumeIsolated depends on.

The core fix is sound and well-tested. The one concern is that updatePanelShellActivityState is not annotated @MainActor, while the notification observer it drives uses MainActor.assumeIsolated to access main-actor-isolated state — a mismatch that compiles silently but crashes if a future call site omits the main-queue dispatch that all current callers include. The annotation is a one-line addition; until it lands, the invariant is enforced only by convention.

Sources/Workspace.swift — updatePanelShellActivityState needs @MainActor to compile-enforce the assumption that sendTextOnNextPromptIdle's observer relies on.

Important Files Changed

Filename Overview
Sources/Workspace.swift Adds sendTextOnNextPromptIdle and notification posting in updatePanelShellActivityState; updatePanelShellActivityState lacks @MainActor while the observer uses MainActor.assumeIsolated, leaving a latent crash if the function is ever called off main.
Sources/TabManager.swift Removes the old timing-based sendWelcomeWhenReady and its 0.5s asyncAfter; delegates to newWorkspace.sendTextOnNextPromptIdle with the shown-flag set synchronously up front. Clean deletion.
Sources/AppDelegate.swift Simplifies sendWelcomeCommandWhenReady to a one-line delegation to workspace.sendTextOnNextPromptIdle; removes the markShownOnSend parameter that was no longer needed.
Sources/TerminalController.swift Adds the .panelShellActivityStateDidChange notification name and the PanelShellActivityNotificationKey constants; straightforward additive change.
cmuxTests/PanelShellActivityNotificationTests.swift Adds four focused regression tests: notification fires on real transitions, deduplication works, sendTextOnNextPromptIdle is one-shot, and no workspace retain cycle when .promptIdle never fires.
cmux.xcodeproj/project.pbxproj Wires PanelShellActivityNotificationTests.swift into both the file references and the test sources build phase; no structural changes.

Sequence Diagram

sequenceDiagram
    participant TM as TabManager.addWorkspace
    participant WS as Workspace
    participant NC as NotificationCenter
    participant TC as TerminalController (shell hook)

    TM->>WS: sendTextOnNextPromptIdle("cmux welcome\n")
    note over WS: fast path: focusedPanel not .promptIdle yet<br/>register [weak self] observer token
    WS->>NC: addObserver(panelShellActivityStateDidChange)
    WS-->>TM: (observer pending)

    TC->>WS: updatePanelShellActivityState(panelId, .promptIdle)
    note over WS: guard previousState != state (dedup)
    WS->>NC: post panelShellActivityStateDidChange (workspaceId, panelId, .promptIdle)
    NC->>WS: observer fires synchronously (queue: nil)
    WS->>WS: MainActor.assumeIsolated → performPromptIdleSend
    WS->>WS: panel.sendText("cmux welcome\n")
    WS->>NC: removeObserver(token)
    WS->>WS: "pendingPromptIdleObservers.removeAll { $0 === token }"
Loading

Reviews (7): Last reviewed commit: "Test: use queue: nil for idempotency obs..." | Re-trigger Greptile

Comment thread Sources/TabManager.swift Outdated
Comment on lines 2695 to 2707
DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) {
Task { @MainActor in
if let readyObserver, !resolved {
NotificationCenter.default.removeObserver(readyObserver)
}
if !resolved {
panelsCancellable?.cancel()
guard !resolved else { return }
if let terminalPanel = workspace.focusedTerminalPanel,
terminalPanel.surface.surface != nil {
resolved = true
cleanup()
performSend(terminalPanel)
} else {
cleanup()
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 New timing-based fallback in rewritten function

DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) is newly introduced timing-based synchronization in the completely rewritten sendWelcomeWhenReady. The allowed-case carve-out in swift-blocking-runtime for existing code the PR "does not introduce or worsen" does not apply here — the function body is entirely new. If the shell is at a running command when the 8-second wall-clock fires (e.g., user kicked off brew install before cmux's text landed), cmux welcome\n is injected unconditionally into that command's stdin. The real signal — panelShellActivityStateDidChange — is already wired; the fallback should instead model "no shell integration" as a distinct, documented state in Workspace so the send can be deferred cleanly without a bare timer.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

Comment thread Sources/AppDelegate.swift Outdated
),
terminalPanel.surface.surface != nil {
send(panel: terminalPanel, mode: "timeoutFallback")
NSLog("Command send: shell prompt not reported within \(fallbackDeadline)s, sending anyway")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 New NSLog bypasses unified logging

NSLog("Command send: shell prompt not reported within \(fallbackDeadline)s, sending anyway") is new production-path logging not guarded by #if DEBUG. The swift-logging rule flags NSLog in app/runtime Swift. The preferred shape is a nonisolated private let logger = Logger(subsystem: Logging.subsystem, category: "SendText") with logger.warning(...) so the message routes through the unified logging system and can be filtered in Console.

Rule Used: Flag production Swift diagnostics that bypass unif... (source)

@juliankmazo juliankmazo left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Blocking from my side, even though GitHub will only let me post this as a comment on my own PR. The notification signal is a good direction, but the current fallback can still type into an active shell-init prompt, and the new regression test has a likely async assertion race.

Comment thread Sources/AppDelegate.swift Outdated
preferredPanelId: preferredPanelId
),
terminalPanel.surface.surface != nil {
send(panel: terminalPanel, mode: "timeoutFallback")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This fallback can recreate the same stdin race for users who do have shell integration but are still blocked in a shell-init prompt after 8s. In that case .promptIdle has not arrived because precmd has not run yet, but we still type cmux welcome\n into whatever is reading stdin, including the oh-my-zsh update prompt. That means the PR does not fully fix the shell-integration case; it just moves the blind timer from 0.5s to 8s. The fallback needs to distinguish “no shell integration signal is possible” from “shell integration exists but the prompt is not idle yet”, or avoid sending while a non-idle state is known.

XCTAssertEqual(notificationCount, 0)

// Real transition — must post exactly once.
workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This assertion is likely racy. The observer was registered with queue: .main, so delivery is queued asynchronously; asserting notificationCount == 1 immediately after posting can fail on CI even if the production post is correct. Existing notification-style tests in this repo usually use queue: nil for synchronous assertions or an expectation. Please make this deterministic before relying on this as the red/green regression proof.

Comment thread Sources/TabManager.swift Outdated
@@ -2620,32 +2620,43 @@ class TabManager: ObservableObject {

@MainActor
private func sendWelcomeWhenReady(to workspace: Workspace) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This duplicates the same observer/resolved/fallback state machine now living in AppDelegate.sendTextWhenReady(waitForShellPromptIdle:). Per the shared behavior policy, the prompt-idle wait should have one implementation that both entrypoints use. The two copies already differ in details and will be easy to regress independently when the timeout/state behavior changes.

@socket-security

socket-security Bot commented May 24, 2026 •

Copy link
Copy Markdown

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

Comment thread Sources/Workspace.swift Outdated
Comment on lines +10796 to +10808
shellActivityObserver = NotificationCenter.default.addObserver(
forName: .panelShellActivityStateDidChange,
object: nil,
queue: .main
) { note in
guard let noteWorkspaceId = note.userInfo?[PanelShellActivityNotificationKey.workspaceId] as? UUID,
noteWorkspaceId == workspaceId,
let state = note.userInfo?[PanelShellActivityNotificationKey.state] as? PanelShellActivityState,
state == .promptIdle
else { return }
Task { @MainActor in attempt() }
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Observer leaked and welcome banner silently dropped for users without shell integration

shellActivityObserver is only deregistered inside attempt(), which only runs when promptIdle fires. For users without cmux shell integration, .promptIdle never fires, so the observer is never removed from NotificationCenter. attempt() is a nested function that implicitly captures self (the Workspace instance) via focusedTerminalPanel and panelShellActivityStates. This gives NotificationCenter a strong reference chain to the workspace, preventing deallocation for the session's lifetime — effectively a workspace-level retain cycle.

Additionally, the PR description and test plan both promise "banner still appears via the 8s fallback" for users without shell integration; the implementation has no fallback at all. Every new workspace created for a user without shell integration will silently skip the welcome banner forever, which is a regression from the pre-PR 3s timeout-based send.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

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

🤖 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/PanelShellActivityNotificationTests.swift`:
- Around line 96-108: The test uses RunLoop.current.run(until: ...) which is
timing-sensitive; replace those ad-hoc sleeps with deterministic main-queue
drains using XCTestExpectation so assertions don't flake. Introduce and call a
small helper (e.g. drainMainQueue or waitForMainQueue) that creates an
XCTestExpectation, dispatches a no-op to DispatchQueue.main.async (or
OperationQueue.main.addOperation) and fulfills the expectation, then calls
wait(for:timeout:); replace each RunLoop.current.run(...) call in
PanelShellActivityNotificationTests with that helper so
workspace.updatePanelShellActivityState and the Task<MainActor> notification
observer reliably run before asserting sendCount.

In `@Sources/AppDelegate.swift`:
- Around line 7614-7621: The welcome send currently relies on
workspace.sendTextOnNextPromptIdle and only sets WelcomeSettings.shownKey inside
the beforeSend closure, so when the panel never reaches
PanelShellActivityState.promptIdle the welcome is silently skipped; update the
flow so sendTextOnNextPromptIdle reports whether the text was actually sent
(e.g., change its completion to include a Bool sent flag or an Error), and when
it reports "not sent" surface a user-facing hint (UI banner/alert or a clear log
message) directing users to run "cmux hooks setup" so they know shell
integration is missing; keep setting WelcomeSettings.shownKey only when sent.

In `@Sources/TabManager.swift`:
- Around line 2610-2613: The welcome send currently only uses
newWorkspace.sendTextOnNextPromptIdle which waits forever if shell integration
is disabled, so retain the previous timeout fallback: update
sendTextOnNextPromptIdle (or add a workspace-specific helper like
sendWelcomeOnNextPromptIdleWithTimeout) to attempt the promptIdle path but also
schedule a fallback send after the grace period; ensure the callback that sets
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) runs in both
the promptIdle success and the timeout fallback branches so the shownKey is
flipped regardless of shell-integration state (targets: autoWelcomeIfNeeded,
select, WelcomeSettings.shownKey, newWorkspace.sendTextOnNextPromptIdle).

In `@Sources/Workspace.swift`:
- Around line 10765-10807: sendTextOnNextPromptIdle currently registers a
NotificationCenter observer (shellActivityObserver) whose closure captures the
nested attempt() and thus the Workspace strongly; if .promptIdle never arrives
the observer is never removed and Workspace is retained. Fix by making the
notification handler capture self weak (e.g., [weak self]) and avoid referencing
attempt() directly from the closure (call a weak-self-safe helper or inline the
minimal check), and ensure the observer token is stored on the Workspace (e.g.,
a property like pendingShellActivityObserver) so it can be removed in a
deterministic lifecycle point (on cancellation or Workspace.deinit) — always
call cleanup() when removing the observer and clear the stored token. Ensure
shellActivityObserver is nil'd after removal to avoid leaks.
🪄 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: 1f3d706f-d680-45ac-8c2d-d51f71f9a8b9

📥 Commits

Reviewing files that changed from the base of the PR and between d4efc88 and 348d832.

📒 Files selected for processing (4)
  • Sources/AppDelegate.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/PanelShellActivityNotificationTests.swift

Comment thread cmuxTests/PanelShellActivityNotificationTests.swift Outdated
Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/TabManager.swift Outdated
Comment thread Sources/Workspace.swift
@juliankmazo

Copy link
Copy Markdown
Author

Thanks — both addressed in the follow-up commits:

  • Fallback can still type into active shell-init prompt — the 8s DispatchQueue.asyncAfter fallback is gone. The project's swift-blocking-runtime.md rule banned it on this latency-sensitive path, but you're right that the more important reason is correctness: any time-based fallback still races the omz prompt. Now if shell integration isn't installed, .promptIdle never fires and the welcome banner is skipped on purpose. PR description updated to call this out clearly instead of promising a fallback that doesn't exist. Commit: 348d8329d.

  • Async assertion race in the regression test — the observer is now registered with queue: nil (synchronous delivery on the poster's thread) and uses MainActor.assumeIsolated to call back into MainActor logic without a Task { @MainActor in ... } hop. The test asserts immediately after each updatePanelShellActivityState call, no runloop spin. A comment in the test pins this invariant so a future refactor that reintroduces an async hop is caught. Commit: 876055c5.

Also fixed an observer leak Greptile flagged on the same pass: tokens are tracked on Workspace and swept in deinit, with [weak self] in the closure as a secondary safety net. New test testSendTextOnNextPromptIdleDoesNotLeakWorkspaceWhenPromptIdleNeverFires covers it.

@juliankmazo

Copy link
Copy Markdown
Author

Thanks for the assertive review. Addressing each of the four findings:

1. Test uses RunLoop.current.run(until: ...) → flake risk. Fixed in 876055c5. The observer is now registered with queue: nil (synchronous delivery on the posting thread) and uses MainActor.assumeIsolated so notifications drive the send without a Task { @MainActor in ... } hop. The test asserts immediately after each updatePanelShellActivityState with no runloop spins. A comment in the test pins this invariant.

2. WelcomeSettings.shownKey only flipped on send → infinite retries when shell integration is missing. Fixed in 936851143. The auto-welcome path now flips shownKey synchronously the first time it attempts to type the welcome, not inside beforeSend. The welcome banner is one-shot onboarding; one attempt per fresh install is the correct semantic, regardless of whether the banner actually rendered. Also dropped the now-dead markShownOnSend parameter on AppDelegate.sendWelcomeCommandWhenReady.

3. TabManager welcome waits forever without shell integration. Deliberately not adding a timing fallback in this PR. The project's swift-blocking-runtime.md rule prohibits new DispatchQueue.asyncAfter on latency-sensitive paths (and was the basis for one of the pre-merge errors on the previous revision). More importantly, any timing fallback would still race the exact bug we're fixing — oh-my-zsh's auto-update prompt blocks stdin indefinitely, so no fixed timeout is safe. With finding #2 fixed, the cost is "users without cmux hooks setup don't see the auto-banner once" rather than "permanent retry loop." A proper UX nudge to run cmux hooks setup belongs with #4515 ("surface cmux hooks setup more visibly to user") which is the actual root cause for this user segment.

If reviewers want the auto-banner to land for users without shell integration, the right plumbing is to write the welcome content to the terminal as output (bypassing the PTY input race entirely) rather than typing — but that needs a new Ghostty surface API and is out of scope for the bug fix.

4. Observer leak in sendTextOnNextPromptIdle. Fixed in 876055c5. The closure captures self weakly, tokens are tracked on Workspace.pendingPromptIdleObservers, and the existing Workspace.deinit sweeps any outstanding observer. New regression test testSendTextOnNextPromptIdleDoesNotLeakWorkspaceWhenPromptIdleNeverFires uses weak var + autoreleasepool to pin this.

Note: the @concurrent finding from the pre-merge checks is a false positive — workspacePullRequestAuthHeaderValue was added by Austin Wang in e39f5e02c8 (April 2026) and is unchanged in this PR.

@juliankmazo

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

♻️ Duplicate comments (1)
cmuxTests/PanelShellActivityNotificationTests.swift (1)

67-83: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use synchronous observer delivery in the idempotency assertion path.

With queue: .main, delivery is enqueued, so the assertion on Line 82 can race and flake. Use queue: nil (or wait via expectation) before asserting.

Proposed minimal fix
         let observer = NotificationCenter.default.addObserver(
             forName: .panelShellActivityStateDidChange,
             object: nil,
-            queue: .main
+            queue: nil
         ) { _ in
             notificationCount += 1
         }
🤖 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 `@cmuxTests/PanelShellActivityNotificationTests.swift` around lines 67 - 83,
The observer registered with NotificationCenter.default.addObserver(forName:
.panelShellActivityStateDidChange, object: nil, queue: .main) enqueues delivery
and can race with the XCTAssertEqual after
workspace.updatePanelShellActivityState; change the observer registration to use
queue: nil (synchronous delivery) for this test path—or alternatively wait on an
XCTestExpectation—so that the idempotency assertion after calling
workspace.updatePanelShellActivityState(panelId:panelId, state:.promptIdle) and
the subsequent real transition are deterministic; update the addObserver call
where notificationCount is incremented and keep the defer removal as-is.
🤖 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.

Duplicate comments:
In `@cmuxTests/PanelShellActivityNotificationTests.swift`:
- Around line 67-83: The observer registered with
NotificationCenter.default.addObserver(forName:
.panelShellActivityStateDidChange, object: nil, queue: .main) enqueues delivery
and can race with the XCTAssertEqual after
workspace.updatePanelShellActivityState; change the observer registration to use
queue: nil (synchronous delivery) for this test path—or alternatively wait on an
XCTestExpectation—so that the idempotency assertion after calling
workspace.updatePanelShellActivityState(panelId:panelId, state:.promptIdle) and
the subsequent real transition are deterministic; update the addObserver call
where notificationCount is incremented and keep the defer removal as-is.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1a3dc21c-b2a0-46bb-859d-e5f90090ce8d

📥 Commits

Reviewing files that changed from the base of the PR and between 9368511 and b6750d7.

📒 Files selected for processing (3)
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/PanelShellActivityNotificationTests.swift

@juliankmazo

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Julian Mazo added 8 commits May 26, 2026 10:05
…anaflow-ai#1900)

Regression test for the dropped-first-char welcome bug: the welcome
command was typed into the user's shell on a 0.5s timer that races
shell init. With oh-my-zsh's auto-update prompt eating stdin, the
'c' of 'cmux welcome' is consumed by the prompt and the rest hits
the shell as 'mux welcome' / 'command not found: mux'.

Fix path is to wait for shell-integration-reported promptIdle
before typing. To make that wait observable, the test asserts that
Workspace.updatePanelShellActivityState posts a notification with
workspaceId, panelId, and the new state.

This commit only adds the declaration and the test; CI is expected
to go red until the follow-up commit adds the post site.
cmux's first-launch welcome was injected by typing 'cmux welcome\n'
into the workspace's PTY 0.5s after the Ghostty surface became ready.
That delay races shell init: oh-my-zsh's 'Would you like to update?'
prompt reads stdin during sourcing, so it eats the 'c' and the rest
of the line arrives at the real shell prompt as 'mux welcome', giving
the user 'zsh: command not found: mux' on every fresh install.

Wait for the shell to report an actual prompt before sending. cmux's
shell integration already drives panelShellActivityStates[panelId]
through promptIdle/commandRunning via Workspace.updatePanelShellActivityState;
this commit makes that update post panelShellActivityStateDidChange so
sendTextWhenReady can subscribe.

- AppDelegate.sendTextWhenReady gains waitForShellPromptIdle, which
  gates the immediate-send branch on promptIdle, attaches a state-change
  observer, extends the readiness deadline to 8s, and SENDS at timeout
  (instead of just cleaning up) so the welcome banner still appears for
  users without cmux shell integration installed.
- sendWelcomeCommandWhenReady opts into the new gating.
- TabManager.sendWelcomeWhenReady (the fallback path used when
  AppDelegate.shared is nil at workspace creation) mirrors the same
  promptIdle wait + 8s send-on-timeout.

Refs manaflow-ai#1900
… timeout

Greptile P1 and CodeRabbit pre-merge checks flagged duplicated coordination
logic in AppDelegate.sendTextWhenReady and TabManager.sendWelcomeWhenReady,
plus the new 8s DispatchQueue.asyncAfter fallback (banned by the project's
swift-blocking-runtime rule on latency-sensitive paths) and an NSLog without
a DEBUG guard.

Move ownership of the wait-for-prompt state machine into Workspace itself:

- Workspace.sendTextOnNextPromptIdle(_:beforeSend:) is the single owner. It
  observes only .panelShellActivityStateDidChange filtered to this workspace,
  fires beforeSend + sendText exactly once on the first promptIdle, and
  cleans up its observer.
- AppDelegate.sendWelcomeCommandWhenReady is now a one-liner that calls into
  the workspace.
- TabManager auto-welcome branch (formerly forked into AppDelegate or
  sendWelcomeWhenReady) calls workspace.sendTextOnNextPromptIdle directly.
- AppDelegate.sendTextWhenReady reverts to its original signature: the
  waitForShellPromptIdle parameter, shell-activity observer, and 8s
  asyncAfter fallback are removed.
- TabManager.sendWelcomeWhenReady is deleted.

No more time-based fallback. If shell integration is not installed the
panel never reaches .promptIdle and the welcome banner is skipped rather
than typed into an unknown stdin reader — that's the architectural
invariant the rules require, and is also the safe choice for users who
might otherwise still hit the omz race with a longer timer.

Test now covers the full one-shot promptIdle semantics. NSLog change
reverted; no new logging is added by this PR. Net diff against main is
118 insertions / 176 deletions — the fix removes more code than it adds.

Refs manaflow-ai#1900
…n test

Greptile's re-review (3/5) flagged two issues on the previous commit:

1. Observer leak. The NotificationCenter observer registered in
   sendTextOnNextPromptIdle was only ever removed inside the success
   path. For workspaces that never saw .promptIdle (no shell integration,
   or workspace closed before integration fired) the observer stayed
   registered, and the implicit self capture through the nested
   attempt() function kept the Workspace alive indefinitely. The block
   itself also leaked.

   Fix:
   - Capture self weakly so NotificationCenter holding the block never
     retains the Workspace.
   - Persist outstanding observer tokens on Workspace
     (pendingPromptIdleObservers) and sweep them in deinit so the block
     is torn out of NotificationCenter as soon as the Workspace goes
     away. A new XCTest covers the deinit sweep with a weak ref plus an
     autoreleasepool.

2. Test was racy. The previous test relied on
   RunLoop.current.run(until: Date().addingTimeInterval(0.05)) after
   each updatePanelShellActivityState to give a Task @mainactor hop
   time to land. On slow CI this would flake.

   Fix:
   - Register the observer with queue: nil so notifications dispatch
     synchronously on the posting thread
     (Workspace.updatePanelShellActivityState is @mainactor, so the
     post is already on main).
   - Use MainActor.assumeIsolated to call back into MainActor-isolated
     send logic without an async hop.
   - The test now asserts immediately after each state-change call —
     no runloop spins, no flakes. A comment in the test pins this
     invariant so a future refactor that re-introduces an async hop
     is caught.

Refactored attempt() into a standalone @mainactor performPromptIdleSend
helper so both the fast path (sync) and the observer path call into
the same checked send routine.

Refs manaflow-ai#1900
CodeRabbit's CHANGES_REQUESTED review on the prior commit flagged that
the auto-welcome path only flipped `WelcomeSettings.shownKey` inside the
`beforeSend` closure passed to `sendTextOnNextPromptIdle`. For users
without cmux shell integration installed `.promptIdle` never fires, so:

1. The key was never flipped.
2. Every subsequent workspace creation re-entered the auto-welcome
   branch and registered another never-fired observer.
3. With the deinit-sweep in place those observers don't leak the
   workspace, but they're still wasted work and the user-visible
   semantic — "first-launch onboarding has been attempted" — was wrong.

Fix the semantic: flip the shown flag synchronously the first time we
attempt to type the welcome. The welcome banner is one-shot onboarding;
one attempt per fresh install is correct regardless of whether the
banner actually rendered. Users who later install shell integration
won't see the auto-banner, but they can run `cmux welcome` from the CLI
at any time.

Also drop the now-dead `markShownOnSend` parameter on
`AppDelegate.sendWelcomeCommandWhenReady` — the only remaining caller
(`openWelcomeWorkspace`, triggered by the user) always used
`markShownOnSend: false`, so the parameter was vestigial.

Refs manaflow-ai#1900
The only remaining pre-merge warning on the last revision was 'Docstring
Coverage 30.77%' against the 80% threshold. Adds doc comments to the
functions and userInfo-key enum introduced by this PR:

- Workspace.updatePanelShellActivityState
- Workspace.performPromptIdleSend
- PanelShellActivityNotificationKey (enum + each constant)
- testStateChangePostsNotificationWithWorkspaceAndPanelIds
- testDuplicateStateDoesNotPostNotification

Pure documentation, no behavior change.
Doc-coverage tooling counts only /// docstrings. The notification name
already had a comment explaining its purpose; this just promotes it to
the form the doc tool recognizes.
CodeRabbit's re-review noted that
testDuplicateStateDoesNotPostNotification's observer was still
registered with queue: .main, so the post-then-assert sequence could
race on slow CI. Switch to queue: nil (synchronous delivery on the
posting thread, which is @mainactor) to match the other tests and the
production observer in Workspace.sendTextOnNextPromptIdle.
@teamleaderleo

Copy link
Copy Markdown
Collaborator

Thank you for the fix! Current main has moved the welcome and shell prompt handling forward, superseding this implementation :)

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