Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3503,6 +3503,7 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]:
"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
Expand All @@ -3515,6 +3516,14 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]:
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

Expand Down Expand Up @@ -3753,6 +3762,9 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str:
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")
Expand All @@ -3772,6 +3784,9 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str:
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:
Expand Down Expand Up @@ -3813,6 +3828,7 @@ def _get_status_bar_fragments(self):
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"]),
Expand All @@ -3825,6 +3841,9 @@ def _get_status_bar_fragments(self):
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),
Expand All @@ -3844,6 +3863,7 @@ def _get_status_bar_fragments(self):
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"]),
Expand All @@ -3860,6 +3880,9 @@ def _get_status_bar_fragments(self):
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),
Expand Down
87 changes: 87 additions & 0 deletions tests/cli/test_cli_background_status_indicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions tools/process_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,19 @@ def close_stdin(self, session_id: str) -> dict:
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:
Expand Down
Loading