From 1c3c364287e9493d885ce23eeff066afe26ee9f5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 25 May 2026 05:35:02 -0700 Subject: [PATCH] feat(cli): show live background terminal-process count in status bar (#32061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI status bar tracked /background agent tasks (▶ N) but not shell processes spawned via terminal(background=true). Both kinds of work can run concurrently and a user has no in-bar signal for shell processes. Add an independent indicator (⚙ N) sourced from tools.process_registry.process_registry._running. The two indicators render side-by-side when both are active (▶ 1 │ ⚙ 2), hidden when their count is zero. Renders at all four status-bar tiers (text fallback + prompt_toolkit fragments, narrow + wide widths). The narrow <52 tier still drops both for space — unchanged. New ProcessRegistry.count_running() returns len(_running) without acquiring _lock; CPython dict len is atomic and we're polling on every status-bar tick, so lock-free is the right tradeoff. --- cli.py | 23 +++++ .../test_cli_background_status_indicator.py | 87 +++++++++++++++++++ tools/process_registry.py | 13 +++ 3 files changed, 123 insertions(+) diff --git a/cli.py b/cli.py index 032fbb4c475..1c527e08312 100644 --- a/cli.py +++ b/cli.py @@ -3503,6 +3503,7 @@ class HermesCLI: "session_api_calls": 0, "compressions": 0, "active_background_tasks": 0, + "active_background_processes": 0, } # Count live /background tasks. The dict entry is removed in the @@ -3515,6 +3516,14 @@ class HermesCLI: except Exception: pass + # Count live background terminal processes (terminal tool background + # sessions tracked by tools.process_registry). Cheap O(1) read. + try: + from tools.process_registry import process_registry + snapshot["active_background_processes"] = process_registry.count_running() + except Exception: + pass + if not agent: return snapshot @@ -3753,6 +3762,9 @@ class HermesCLI: bg_count = snapshot.get("active_background_tasks", 0) if bg_count: parts.append(f"▶ {bg_count}") + bg_proc_count = snapshot.get("active_background_processes", 0) + if bg_proc_count: + parts.append(f"⚙ {bg_proc_count}") parts.append(duration_label) if yolo_active: parts.append("⚠ YOLO") @@ -3772,6 +3784,9 @@ class HermesCLI: bg_count = snapshot.get("active_background_tasks", 0) if bg_count: parts.append(f"▶ {bg_count}") + bg_proc_count = snapshot.get("active_background_processes", 0) + if bg_proc_count: + parts.append(f"⚙ {bg_proc_count}") parts.append(duration_label) prompt_elapsed = snapshot.get("prompt_elapsed") if prompt_elapsed: @@ -3813,6 +3828,7 @@ class HermesCLI: if width < 76: compressions = snapshot.get("compressions", 0) bg_count = snapshot.get("active_background_tasks", 0) + bg_proc_count = snapshot.get("active_background_processes", 0) frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), @@ -3825,6 +3841,9 @@ class HermesCLI: if bg_count: frags.append(("class:status-bar-dim", " · ")) frags.append(("class:status-bar-strong", f"▶ {bg_count}")) + if bg_proc_count: + frags.append(("class:status-bar-dim", " · ")) + frags.append(("class:status-bar-strong", f"⚙ {bg_proc_count}")) frags.extend([ ("class:status-bar-dim", " · "), ("class:status-bar-dim", duration_label), @@ -3844,6 +3863,7 @@ class HermesCLI: bar_style = self._status_bar_context_style(percent) compressions = snapshot.get("compressions", 0) bg_count = snapshot.get("active_background_tasks", 0) + bg_proc_count = snapshot.get("active_background_processes", 0) frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), @@ -3860,6 +3880,9 @@ class HermesCLI: if bg_count: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-strong", f"▶ {bg_count}")) + if bg_proc_count: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-strong", f"⚙ {bg_proc_count}")) frags.extend([ ("class:status-bar-dim", " │ "), ("class:status-bar-dim", duration_label), diff --git a/tests/cli/test_cli_background_status_indicator.py b/tests/cli/test_cli_background_status_indicator.py index 32f39f96650..047dca77cb3 100644 --- a/tests/cli/test_cli_background_status_indicator.py +++ b/tests/cli/test_cli_background_status_indicator.py @@ -102,3 +102,90 @@ def test_fragments_omit_bg_segment_when_idle(): frags = cli_obj._get_status_bar_fragments() rendered = "".join(text for _style, text in frags) assert "▶" not in rendered + + +# ── Background terminal-process indicator (⚙ N) ─────────────────────────── +# Source of truth is tools.process_registry.process_registry._running (a dict +# of currently-running shell processes spawned by terminal(background=true)). +# Distinct from /background tasks above: ▶ counts agent threads, ⚙ counts +# shell processes. Both can be active simultaneously. + + +class _FakeRunningRegistry: + """Minimal stand-in for process_registry; exposes count_running().""" + + def __init__(self, count: int) -> None: + self._count = count + + def count_running(self) -> int: + return self._count + + +def _patch_process_registry(monkeypatch, count: int) -> None: + import tools.process_registry as pr_mod + monkeypatch.setattr(pr_mod, "process_registry", _FakeRunningRegistry(count)) + + +def test_snapshot_reports_zero_when_no_background_processes(monkeypatch): + cli_obj = _make_cli() + _patch_process_registry(monkeypatch, 0) + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_processes"] == 0 + + +def test_snapshot_counts_live_background_processes(monkeypatch): + cli_obj = _make_cli() + _patch_process_registry(monkeypatch, 3) + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_processes"] == 3 + + +def test_snapshot_safe_when_process_registry_raises(monkeypatch): + """If count_running() raises the snapshot stays at 0; no propagate.""" + cli_obj = _make_cli() + import tools.process_registry as pr_mod + + class _BoomRegistry: + def count_running(self): + raise RuntimeError("boom") + + monkeypatch.setattr(pr_mod, "process_registry", _BoomRegistry()) + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_processes"] == 0 + + +def test_plain_text_status_shows_proc_indicator_when_active(monkeypatch): + cli_obj = _make_cli() + _patch_process_registry(monkeypatch, 2) + text = cli_obj._build_status_bar_text(width=80) + assert "⚙ 2" in text + + +def test_plain_text_status_omits_proc_indicator_when_idle(monkeypatch): + cli_obj = _make_cli() + _patch_process_registry(monkeypatch, 0) + text = cli_obj._build_status_bar_text(width=80) + assert "⚙" not in text + + +def test_fragments_include_proc_segment_when_active(monkeypatch): + cli_obj = _make_cli() + _patch_process_registry(monkeypatch, 1) + cli_obj._status_bar_visible = True + cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign] + frags = cli_obj._get_status_bar_fragments() + rendered = "".join(text for _style, text in frags) + assert "⚙ 1" in rendered + + +def test_indicators_independent_agents_and_processes(monkeypatch): + """▶ (agent tasks) and ⚙ (shell processes) render side-by-side.""" + cli_obj = _make_cli() + cli_obj._background_tasks = {"bg_a": _stub_thread()} + _patch_process_registry(monkeypatch, 2) + cli_obj._status_bar_visible = True + cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign] + frags = cli_obj._get_status_bar_fragments() + rendered = "".join(text for _style, text in frags) + assert "▶ 1" in rendered + assert "⚙ 2" in rendered diff --git a/tools/process_registry.py b/tools/process_registry.py index 38c35b3c5a0..f739b51ea2c 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1235,6 +1235,19 @@ class ProcessRegistry: except Exception as e: return {"status": "error", "error": str(e)} + def count_running(self) -> int: + """Return the count of currently-running background processes. + + Cheap O(1) read of the running dict, suitable for status-bar polling + on every render tick. CPython dict ``len()`` is atomic; callers do not + need to hold ``self._lock``. Reflects ``_running`` only: sessions are + moved to ``_finished`` when their subprocess exits. + """ + try: + return len(self._running) + except Exception: + return 0 + def list_sessions(self, task_id: str = None) -> list: """List all running and recently-finished processes.""" with self._lock: