diff --git a/cecli/tui/app.py b/cecli/tui/app.py index cc4b884583f..84fb36d6ca5 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -60,6 +60,12 @@ class TUI(App): # Binding("escape", "interrupt", "Interrupt", show=True), ] + # How often (seconds) to poll the foreground coder's git branch and + # refresh the footer. 1s satisfies the <5s detection-to-UI SLA with wide + # margin while keeping CPU load negligible (GitPython reads .git/HEAD + # directly, no subprocess). + GIT_BRANCH_POLL_INTERVAL = 1.0 + def __init__(self, coder_worker, output_queue, input_queue, args): """Initialize the cecli TUI app.""" super().__init__() @@ -402,6 +408,43 @@ def on_mount(self): # Load git info in background to avoid blocking startup self.call_later(self._load_git_info) + self.set_interval(self.GIT_BRANCH_POLL_INTERVAL, self._refresh_git_branch) + + def _refresh_git_branch(self): + """Poll the foreground coder's git branch and update the footer. + + Runs on the UI thread via set_interval; safe for reactive updates. + Short-circuits when no repo is present. + + ponytail: 1s polling ceiling. Upgrade path = watchfiles/watchdog on + .git/HEAD if latency requirements ever tighten below 1s + (unlikely; 1s << 5s SLA). + """ + try: + coder = self._get_visible_coder() + except (AttributeError, TypeError): + return # worker/coder not fully initialized or shutting down + + repo = getattr(coder, "repo", None) + footer = self.query_one(MainFooter) + + if repo is None: + # Clear stale branch display only if we previously showed one + if footer.git_branch: + footer.update_git("") + return + + try: + branch = repo.repo.active_branch.name + except (TypeError, AttributeError): + # Detached HEAD (TypeError) or any git plumbing error + return # preserve prior footer text instead of blanking + + if branch != footer.git_branch: + footer.update_git(branch) + + # ponytail: 1s polling ceiling. Upgrade path = watchfiles/watchdog on .git/HEAD + # if latency requirements ever tighten below 1s (unlikely; 1s << 5s SLA). def on_mouse_down(self, event: events.MouseDown) -> None: """Handle mouse down events to start the selection hint timer.""" diff --git a/requirements.txt b/requirements.txt index 56d75692d3a..b4a57cc1874 100644 --- a/requirements.txt +++ b/requirements.txt @@ -594,7 +594,7 @@ uvicorn[standard]==0.38.0 # -c requirements/common-constraints.txt # chromadb # mcp -uvloop==0.22.1 +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' # via # -c requirements/common-constraints.txt # uvicorn diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py index 88f4fc7ddf0..bd9944b9e69 100644 --- a/tests/tui/test_app.py +++ b/tests/tui/test_app.py @@ -346,3 +346,126 @@ def test_on_input_area_submit_intercepts_spawn_agent(tui_instance): "/spawn-agent reviewer review the code", "/spawn-agent reviewer review the code", ) + + +# --------------------------------------------------------------------------- +# Git branch dynamic update (_refresh_git_branch) — CLI-63 +# --------------------------------------------------------------------------- + + +class _DetachedHeadBranch: + """Simulates GitPython's active_branch in a detached HEAD state. + + Accessing ``.name`` raises ``AttributeError``, matching the real + ``git.refs.symbolic.SymbolicReference`` behavior when HEAD is detached. + """ + + @property + def name(self): + raise AttributeError("detached HEAD") + + +def _make_footer(branch=""): + """Create a mock MainFooter with a git_branch attribute.""" + footer = MagicMock() + footer.git_branch = branch + return footer + + +def _make_coder(repo=None): + """Create a mock coder with an optional repo.""" + coder = MagicMock() + coder.repo = repo + return coder + + +def _make_repo(branch="main"): + """Create a mock repo wrapper exposing .repo.active_branch.name.""" + repo = MagicMock() + repo.repo.active_branch.name = branch + return repo + + +def _wire_refresh(tui_instance, coder, footer): + """Wire the mocks needed by _refresh_git_branch onto the TUI instance.""" + tui_instance._get_visible_coder = MagicMock(return_value=coder) + tui_instance.query_one = MagicMock(return_value=footer) + + +def test_refresh_git_branch_updates_on_change(tui_instance): + """update_git is called with the new branch when the branch changes.""" + footer = _make_footer(branch="main") + coder = _make_coder(repo=_make_repo(branch="feature-alpha")) + _wire_refresh(tui_instance, coder, footer) + + tui_instance._refresh_git_branch() + + footer.update_git.assert_called_once_with("feature-alpha") + + +def test_refresh_git_branch_no_update_when_unchanged(tui_instance): + """update_git is NOT called when the branch name is unchanged.""" + footer = _make_footer(branch="main") + coder = _make_coder(repo=_make_repo(branch="main")) + _wire_refresh(tui_instance, coder, footer) + + tui_instance._refresh_git_branch() + + footer.update_git.assert_not_called() + + +def test_refresh_git_branch_no_repo_clears_stale_branch(tui_instance): + """No repo clears a previously displayed branch (update_git('')).""" + footer = _make_footer(branch="main") + coder = _make_coder(repo=None) + _wire_refresh(tui_instance, coder, footer) + + tui_instance._refresh_git_branch() + + footer.update_git.assert_called_once_with("") + + +def test_refresh_git_branch_no_repo_empty_branch_noop(tui_instance): + """No repo with an already-empty branch does not call update_git.""" + footer = _make_footer(branch="") + coder = _make_coder(repo=None) + _wire_refresh(tui_instance, coder, footer) + + tui_instance._refresh_git_branch() + + footer.update_git.assert_not_called() + + +def test_refresh_git_branch_detached_head_silent(tui_instance): + """AttributeError on active_branch access returns silently (preserves text).""" + footer = _make_footer(branch="main") + repo = MagicMock() + repo.repo.active_branch = _DetachedHeadBranch() + coder = _make_coder(repo=repo) + _wire_refresh(tui_instance, coder, footer) + + tui_instance._refresh_git_branch() + + footer.update_git.assert_not_called() + + +def test_refresh_git_branch_visible_coder_error_silent(tui_instance): + """AttributeError from _get_visible_coder returns silently (no query_one).""" + tui_instance._get_visible_coder = MagicMock(side_effect=AttributeError("not initialized")) + tui_instance.query_one = MagicMock() + + tui_instance._refresh_git_branch() + + tui_instance.query_one.assert_not_called() + + +def test_refresh_git_branch_uses_foreground_coder(tui_instance): + """The branch is read from the foreground coder's repo (sub-agent aware).""" + footer = _make_footer(branch="main") + sub_agent_coder = _make_coder(repo=_make_repo(branch="sub-agent-branch")) + _wire_refresh(tui_instance, sub_agent_coder, footer) + + tui_instance._refresh_git_branch() + + tui_instance._get_visible_coder.assert_called_once() + footer.update_git.assert_called_once_with("sub-agent-branch")