#!/usr/bin/env python3 """ Regression test: terminal focus must track the visible/focused surface across split operations. Why: we've seen cases where the focused surface highlights correctly, but AppKit first responder remains on another (often detached) terminal view. Users then type but nothing appears (input is routed elsewhere). This test validates: 1) The focused terminal is actually first responder (`is_terminal_focused`). 2) Text insertion via debug socket (`simulate_type`) lands in the expected terminal by writing $CMUX_SURFACE_ID to a temp file. """ import os import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from cmux import cmux, cmuxError SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") FOCUS_FILE = Path("/tmp/cmux_focus_routing.txt") def _focused_surface_id(c: cmux) -> str: surfaces = c.list_surfaces() for _, sid, focused in surfaces: if focused: return sid raise cmuxError(f"No focused surface in list_surfaces: {surfaces}") def _wait_for_file_content(path: Path, timeout_s: float = 3.0) -> str: start = time.time() while time.time() - start < timeout_s: if path.exists(): try: data = path.read_text().strip() except Exception: data = "" if data: return data time.sleep(0.05) raise cmuxError(f"Timed out waiting for file content: {path}") def _wait_for_terminal_focus(c: cmux, panel_id: str, timeout_s: float = 6.0) -> None: start = time.time() while time.time() - start < timeout_s: if c.is_terminal_focused(panel_id): return time.sleep(0.05) raise cmuxError(f"Timed out waiting for terminal focus: {panel_id}") def _focus_and_wait(c: cmux, panel_id: str, *, total_timeout_s: float = 8.0) -> None: """ Focus can be racy under split/tree churn. Re-issue focus a few times before failing. """ deadline = time.time() + total_timeout_s last_err = None attempt = 0 while time.time() < deadline and attempt < 4: attempt += 1 try: c.activate_app() except Exception: pass try: c.focus_surface_by_panel(panel_id) except Exception as e: last_err = e time.sleep(0.15) continue time.sleep(0.2) try: _wait_for_terminal_focus(c, panel_id, timeout_s=2.5) return except Exception as e: last_err = e time.sleep(0.15) raise cmuxError(f"Failed to focus terminal surface (panel_id={panel_id}): {last_err}") def _assert_routed_to_surface(c: cmux, expected_surface_id: str, panel_id: str) -> None: last_actual = "" for attempt in range(4): _focus_and_wait(c, panel_id, total_timeout_s=4.0) if FOCUS_FILE.exists(): try: FOCUS_FILE.unlink() except Exception: pass # Write the currently focused surface id into a well-known file. c.simulate_type(f"echo $CMUX_SURFACE_ID > {FOCUS_FILE}") c.simulate_shortcut("enter") try: actual = _wait_for_file_content(FOCUS_FILE, timeout_s=3.0 + (attempt * 0.5)) except cmuxError: actual = "" if actual == expected_surface_id: return last_actual = actual or "" time.sleep(0.15) raise cmuxError( f"Input routed to wrong surface after retries: expected={expected_surface_id} actual={last_actual}" ) def main() -> int: with cmux(SOCKET_PATH) as c: # Isolate from any user workspace state. c.new_workspace() time.sleep(0.2) # Focus-sensitive assertions require the main window to be key. # When launched via SSH, `open` does not always activate the app. c.activate_app() time.sleep(0.2) # Create a bunch of terminals to stress layout/focus code paths. for _ in range(12): c.new_surface(panel_type="terminal") time.sleep(0.02) surfaces = c.list_surfaces() if not surfaces: raise cmuxError("Expected at least one surface after new_workspace") left_id = surfaces[0][1] # Create a split to the right (this may trigger bonsplit reparenting/structural updates). right_id = c.new_split("right") if not right_id: # Should not happen with current server, but keep a fallback for older behavior. right_id = _focused_surface_id(c) time.sleep(0.25) # Focus left then right, verifying both first responder and input routing. _focus_and_wait(c, left_id, total_timeout_s=8.0) _assert_routed_to_surface(c, left_id, left_id) _focus_and_wait(c, right_id, total_timeout_s=8.0) _assert_routed_to_surface(c, right_id, right_id) # Stress: repeated split/close should never leave focus on a detached/hidden terminal. for _ in range(10): new_id = c.new_split("right") time.sleep(0.1) _focus_and_wait(c, new_id, total_timeout_s=8.0) _assert_routed_to_surface(c, new_id, new_id) c.close_surface(new_id) time.sleep(0.25) focused = _focused_surface_id(c) _focus_and_wait(c, focused, total_timeout_s=8.0) _assert_routed_to_surface(c, focused, focused) print("PASS: terminal focus routing") return 0 if __name__ == "__main__": raise SystemExit(main())