Skip to content

Fix terminal keyboard focus after workspace switch (#1122) - #1124

Closed
austinywang wants to merge 2 commits into
mainfrom
issue-1122-keyboard-unresponsive-workspace-switch
Closed

austinywang wants to merge 2 commits into
mainfrom
issue-1122-keyboard-unresponsive-workspace-switch

Conversation

@austinywang

@austinywang austinywang commented Mar 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a regression test that exercises real first-responder typing after switching away from and back to a workspace
  • keep terminal focus restoration retries alive across transient workspace activation/remount states and cancel stale retries when focus intent changes
  • reassert the selected terminal after the retiring workspace yields first responder so keyboard input is not left stranded on nil

Testing

  • ./scripts/reload.sh --tag issue-1122-keyboard-unresponsive
  • Local tests not run per repo policy

Summary by cubic

Fixes lost keyboard input after switching workspaces by reliably restoring terminal focus. Adds a regression test to ensure typing reaches the selected terminal via the real first-responder path.

  • Bug Fixes
    • Added a bounded, generation-based focus retry that survives activation/remount and cancels stale attempts.
    • Reasserted the selected terminal after the retiring workspace clears first responder to avoid nil focus.
    • Verified with tests/test_issue_1122_workspace_switch_keyboard.py, which simulates switch cycles and checks that typing routes to the selected terminal.

Written for commit 87ef640. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced keyboard focus handling when switching workspaces to prevent keyboard input from becoming unresponsive during rapid workspace transitions.
    • Added safety focus reassertion mechanism to ensure keyboard input is properly routed to the active terminal after workspace switches.
  • Tests

    • Added regression test to verify keyboard input resilience across workspace switch operations.

@vercel

vercel Bot commented Mar 9, 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 Mar 9, 2026 11:51pm

@coderabbitai

coderabbitai Bot commented Mar 9, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces generation-based focus guarding to prevent race conditions during workspace switching. It enhances the ensureFocus method in GhosttyTerminalView with retry delays and stale-request detection, adds a safety re-focus step in TabManager after workspace unfocus, and includes a regression test for keyboard focus resilience across workspace switches.

Changes

Cohort / File(s) Summary
Focus Reassertion with Generation Guards
Sources/GhosttyTerminalView.swift
Introduces focusRequestGeneration counter and bounded retry mechanism with generation guards to prevent stale focus retries during workspace reparenting. Updates ensureFocus method signature to accept optional requestGeneration parameter, increases default retry attempts to 6, adds early validation checks (AppDelegate/tab manager resolution, target tab selection), and strengthens preconditions on surface state (active, visible, valid window).
TabManager Re-focus Step
Sources/TabManager.swift
Adds safety focus reassertion by calling ensureFocusedTerminalFirstResponder() immediately after completing workspace unfocus operations to prevent keyboard input stranding when first responder is cleared.
Workspace Switch Keyboard Focus Test
tests/test_issue_1122_workspace_switch_keyboard.py
New regression test that validates keyboard focus resilience across rapid workspace switches, including helper functions for polling terminal focus state and verifying input routing to the currently selected terminal.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant GTV as GhosttyTerminalView
    participant TM as TabManager
    participant AD as AppDelegate

    User->>GTV: ensureFocus(tabId, surfaceId, requestGeneration: Gen1)
    
    alt Early Validation Fails
        GTV->>GTV: Check AppDelegate/TabManager available
        GTV->>GTV: Verify target tab is selected
        Note over GTV: Conditions not met
        GTV->>GTV: Schedule retry with focusEnsureRetryDelay
        GTV-->>User: (retry scheduled)
    end
    
    alt Precondition Check Fails
        GTV->>GTV: Verify surface active, visible
        GTV->>GTV: Verify window valid
        Note over GTV: Preconditions not satisfied
        GTV->>GTV: Schedule retry with generation guard
        GTV-->>User: (retry scheduled)
    end
    
    alt Retry Arrives with Stale Generation
        GTV->>GTV: Check generation == Gen1
        Note over GTV: Generation mismatch (Gen2 is current)
        GTV->>GTV: Discard stale retry
        GTV-->>User: (retry discarded)
    end
    
    alt Conditions Met & Generation Current
        GTV->>AD: Focus surface
        AD->>TM: Activate workspace
        TM->>GTV: ensureFocusedTerminalFirstResponder()
        GTV-->>User: Focus established ✓
    end
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related issues

  • Terminal becomes unresponsive to keyboard input after switching workspaces #1122: The generation-based retry mechanism and TabManager re-focus step directly address first-responder restoration and race conditions when switching workspaces.
  • manaflow-ai/cmux-dev-artifacts#180: The added visibility checks, precondition validation, and TabManager re-focus directly address workspace visibility and focus timing races that likely cause terminal UI test failures.

Possibly related PRs

Poem

🐰 A focused tale of racing throughs,
With generations guarding views,
When workspaces leap and switch about,
The keyboard knows what it's all about! 🎯

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: restoring terminal keyboard focus after workspace switches, directly addressing issue #1122.
Description check ✅ Passed The description covers all template sections: Summary (what/why), Testing (method and verification), and includes bot review checklist items; however, no demo video is provided despite the template requesting one for behavior changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issue-1122-keyboard-unresponsive-workspace-switch

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

@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 the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 6181-6187: The current guard uses a fallback delegate.tabManager
which can be unrelated during workspace remounts and causes the retry() chain to
be dropped; change the logic to only proceed when
AppDelegate.shared.tabManagerFor(tabId:) returns a non-nil manager for the
target tabId and otherwise call retry() and return (i.e., remove the fallback to
delegate.tabManager), then keep the existing check against
tabManager.selectedTabId == tabId; reference AppDelegate.shared,
tabManagerFor(tabId:), tabManager, retry(), and tabManager.selectedTabId to
locate and update the code.

In `@tests/test_issue_1122_workspace_switch_keyboard.py`:
- Around line 67-91: The test creates a temporary file FOCUS_FILE but never
removes it; update main() to ensure FOCUS_FILE is deleted after the test
completes by adding cleanup (e.g., call FOCUS_FILE.unlink(missing_ok=True))
either in a finally block wrapping the existing with cmux(...) block or
immediately after the success path (after the assertions/print) so cleanup runs
on both success and failure; reference the FOCUS_FILE symbol and the main()
function (and keep the existing cmux(...) / _wait_for_terminal_focus(...) logic
unchanged).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d720ce0d-339f-45b5-81d2-8c042d6e7a90

📥 Commits

Reviewing files that changed from the base of the PR and between 4ba9675 and 87ef640.

📒 Files selected for processing (3)
  • Sources/GhosttyTerminalView.swift
  • Sources/TabManager.swift
  • tests/test_issue_1122_workspace_switch_keyboard.py

Comment on lines +6181 to +6187
guard let delegate = AppDelegate.shared,
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager else {
retry()
return
}
guard tabManager.selectedTabId == tabId else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't drop the retry chain when the fallback manager is unrelated.

Sources/AppDelegate.swift:8796-8798 resolves tabManagerFor(tabId:) through contextContainingTabId(tabId), so it can legitimately be nil while a workspace is remounting. In that window the fallback delegate.tabManager may belong to a different window, and Line 6186 drops this request's retry chain instead of waiting for the target workspace selection to converge. That can still leave keyboard focus stranded after a slow switch.

Suggested fix
-        guard tabManager.selectedTabId == tabId else { return }
+        guard tabManager.selectedTabId == tabId else {
+            retry()
+            return
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 6181 - 6187, The current
guard uses a fallback delegate.tabManager which can be unrelated during
workspace remounts and causes the retry() chain to be dropped; change the logic
to only proceed when AppDelegate.shared.tabManagerFor(tabId:) returns a non-nil
manager for the target tabId and otherwise call retry() and return (i.e., remove
the fallback to delegate.tabManager), then keep the existing check against
tabManager.selectedTabId == tabId; reference AppDelegate.shared,
tabManagerFor(tabId:), tabManager, retry(), and tabManager.selectedTabId to
locate and update the code.

Comment on lines +67 to +91
def main() -> int:
with cmux(SOCKET_PATH) as c:
ws_a = c.new_workspace()
time.sleep(0.3)
c.activate_app()
time.sleep(0.2)
panel_a = _selected_terminal_panel_id(c)

ws_b = c.new_workspace()
time.sleep(0.3)

for _ in range(6):
c.select_workspace(ws_a)
time.sleep(0.12)
c.select_workspace(ws_b)
time.sleep(0.12)

c.select_workspace(ws_a)
time.sleep(0.2)

_wait_for_terminal_focus(c, panel_a, timeout_s=3.0)
_assert_typed_input_routes_to_selected_terminal(c, panel_a)

print("PASS: workspace switch-back restores terminal keyboard focus")
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Clean up the temp file after test completion.

FOCUS_FILE is created during the test but not removed after a successful run. Consider adding cleanup in a finally block or after the assertion to avoid leaving stale temp files.

🧹 Proposed fix to add cleanup
         _wait_for_terminal_focus(c, panel_a, timeout_s=3.0)
         _assert_typed_input_routes_to_selected_terminal(c, panel_a)
 
+    FOCUS_FILE.unlink(missing_ok=True)
     print("PASS: workspace switch-back restores terminal keyboard focus")
     return 0

Alternatively, for cleanup on both success and failure:

def main() -> int:
    try:
        with cmux(SOCKET_PATH) as c:
            # ... existing test code ...
        print("PASS: workspace switch-back restores terminal keyboard focus")
        return 0
    finally:
        FOCUS_FILE.unlink(missing_ok=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_issue_1122_workspace_switch_keyboard.py` around lines 67 - 91, The
test creates a temporary file FOCUS_FILE but never removes it; update main() to
ensure FOCUS_FILE is deleted after the test completes by adding cleanup (e.g.,
call FOCUS_FILE.unlink(missing_ok=True)) either in a finally block wrapping the
existing with cmux(...) block or immediately after the success path (after the
assertions/print) so cleanup runs on both success and failure; reference the
FOCUS_FILE symbol and the main() function (and keep the existing cmux(...) /
_wait_for_terminal_focus(...) logic unchanged).

@greptile-apps

greptile-apps Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a keyboard-unresponsive regression (#1122) where switching away from and back to a workspace could leave the terminal with no AppKit first-responder, requiring the user to click the terminal before typing would work. Two root causes are addressed: the retiring workspace synchronously clearing first-responder to nil, and transient reattachment states (isActive == false, missing window) silently aborting the focus restoration path instead of retrying through them.

Key changes:

  • TabManager.completePendingWorkspaceUnfocus now calls ensureFocusedTerminalFirstResponder() immediately after unfocusWorkspacePanel, re-asserting the selected terminal as first-responder once the retiring workspace yields focus.
  • GhosttySurfaceScrollView.ensureFocus gains a focusRequestGeneration counter (wrapping UInt64) so any new focus intent or an explicit cancelFocusRequest() call atomically invalidates all in-flight retries from a prior request.
  • isActive and window guard failures now schedule a retry (previously they silently returned), extending the retry window through transient activation states.
  • Default attemptsRemaining is raised from 3 → 6 (180 ms total window at 30 ms per retry).
  • cancelFocusRequest() was previously a no-op; it now increments the generation counter to actually cancel pending retries (used when the find bar opens).
  • A regression test exercises 6 rapid switch-away / switch-back cycles and asserts both AppKit first-responder state and real first-responder keyboard routing via shell command output.

Confidence Score: 4/5

  • Safe to merge; the fix is narrowly scoped to focus-restoration retry logic and the new completePendingWorkspaceUnfocus call cannot regress unrelated paths.
  • The Swift logic is sound: generation-gated retries correctly invalidate stale closures, and the ensureFocusedTerminalFirstResponder call is placed after unfocusWorkspacePanel at a point where selectedTabId is already up to date. The retry-suppression for selectedTabId != tabId correctly stops pursuing focus for workspaces no longer selected. The regression test covers the reported scenario end-to-end.
  • No files require special attention.

Sequence Diagram

sequenceDiagram
    participant User
    participant TabManager
    participant WorkspaceB as Workspace B (retiring)
    participant WorkspaceA as Workspace A (selected)
    participant ScrollView as GhosttySurfaceScrollView

    User->>TabManager: select_workspace(ws_a)
    TabManager->>TabManager: selectedTabId = ws_a
    TabManager->>WorkspaceB: pendingWorkspaceUnfocus scheduled
    Note over TabManager: completePendingWorkspaceUnfocus fires
    TabManager->>WorkspaceB: unfocusWorkspacePanel()
    WorkspaceB-->>TabManager: first responder cleared (→ nil)
    TabManager->>TabManager: ensureFocusedTerminalFirstResponder()
    TabManager->>ScrollView: ensureFocus(tabId: ws_a, surfaceId: panelId)
    activate ScrollView
    Note over ScrollView: focusRequestGeneration &+= 1<br/>generation = N
    alt workspace reattaching (isActive=false / no window)
        ScrollView->>ScrollView: retry() [up to 6×, 30ms apart]
        Note over ScrollView: each retry checks generation == N<br/>stale retries cancelled if generation advances
    else workspace ready
        ScrollView->>WorkspaceA: window.makeFirstResponder(surfaceView)
    end
    deactivate ScrollView

    Note over ScrollView: cancelFocusRequest() bumps generation<br/>→ all in-flight retries for old generation stop
Loading

Last reviewed commit: 87ef640

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

No issues found across 3 files

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — 87ef6402 Deployed Mar 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants