diff --git a/cli.py b/cli.py index 3b915ac5666..62b1cac6fca 100644 --- a/cli.py +++ b/cli.py @@ -5122,6 +5122,23 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): except Exception: pass + # Standing /goal state (Ralph loop). GoalManager is cached on self and + # keeps its state in memory, so this is a cheap attribute read โ€” no DB + # hit per repaint. Only an *active* goal earns a segment; paused/done + # goals stay out of the bar (matching the desktop's active-first row). + snapshot["goal_active"] = False + snapshot["goal_turns_used"] = 0 + snapshot["goal_max_turns"] = 0 + try: + goal_mgr = self._get_goal_manager() + if goal_mgr is not None and goal_mgr.is_active(): + goal_state = goal_mgr.state + snapshot["goal_active"] = True + snapshot["goal_turns_used"] = int(getattr(goal_state, "turns_used", 0) or 0) + snapshot["goal_max_turns"] = int(getattr(goal_state, "max_turns", 0) or 0) + except Exception: + pass + if not agent: return snapshot @@ -5564,6 +5581,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): cont = " | Continuous" if self._voice_continuous else "" return [("class:voice-status", f" ๐ŸŽค Voice mode{tts}{cont} โ€” {label} to record ")] + @staticmethod + def _status_bar_goal_segment(snapshot: Dict[str, Any]) -> str: + """Return the ``โŠ™ goal 3/20`` segment, or ``""`` when no goal is active. + + Active-goal-only by design: paused/done goals don't occupy status-bar + real estate (they already print their own glyph lines in the thread). + """ + if not snapshot.get("goal_active"): + return "" + used = snapshot.get("goal_turns_used") or 0 + max_turns = snapshot.get("goal_max_turns") or 0 + if max_turns: + return f"โŠ™ goal {used}/{max_turns}" + return "โŠ™ goal" + def _build_status_bar_text(self, width: Optional[int] = None) -> str: """Return a compact one-line session status string for the TUI footer.""" try: @@ -5577,8 +5609,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): battery_prefix = f"{battery_label} โ”‚ " if battery_label else "" yolo_active = self._is_session_yolo_active() + goal_segment = self._status_bar_goal_segment(snapshot) if width < 52: text = f"{battery_prefix}โš• {snapshot['model_short']} ยท {duration_label}" + if goal_segment: + text += f" ยท {goal_segment}" if yolo_active: text += " ยท โš  YOLO" return self._trim_status_bar_text(text, width) @@ -5598,6 +5633,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): bg_subagent_count = snapshot.get("active_background_subagents", 0) if bg_subagent_count: parts.append(f"โ›“ {bg_subagent_count}") + if goal_segment: + parts.append(goal_segment) parts.append(duration_label) if yolo_active: parts.append("โš  YOLO") @@ -5625,6 +5662,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): bg_subagent_count = snapshot.get("active_background_subagents", 0) if bg_subagent_count: parts.append(f"โ›“ {bg_subagent_count}") + if goal_segment: + parts.append(goal_segment) parts.append(duration_label) prompt_elapsed = snapshot.get("prompt_elapsed") if prompt_elapsed: @@ -5651,6 +5690,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): width = self._get_tui_terminal_width() duration_label = snapshot["duration"] yolo_active = self._is_session_yolo_active() + goal_segment = self._status_bar_goal_segment(snapshot) battery_label = snapshot.get("battery_label") or "" battery_style = self._battery_status_style(snapshot.get("battery_category", "dim")) @@ -5661,6 +5701,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): ("class:status-bar-dim", " ยท "), ("class:status-bar-dim", duration_label), ] + if goal_segment: + frags.append(("class:status-bar-dim", " ยท ")) + frags.append(("class:status-bar-strong", goal_segment)) if yolo_active: frags.append(("class:status-bar-dim", " ยท ")) frags.append(("class:status-bar-yolo", "โš  YOLO")) @@ -5691,6 +5734,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if bg_subagent_count: frags.append(("class:status-bar-dim", " ยท ")) frags.append(("class:status-bar-strong", f"โ›“ {bg_subagent_count}")) + if goal_segment: + frags.append(("class:status-bar-dim", " ยท ")) + frags.append(("class:status-bar-strong", goal_segment)) frags.extend([ ("class:status-bar-dim", " ยท "), ("class:status-bar-dim", duration_label), @@ -5734,6 +5780,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if bg_subagent_count: frags.append(("class:status-bar-dim", " โ”‚ ")) frags.append(("class:status-bar-strong", f"โ›“ {bg_subagent_count}")) + if goal_segment: + frags.append(("class:status-bar-dim", " โ”‚ ")) + frags.append(("class:status-bar-strong", goal_segment)) frags.extend([ ("class:status-bar-dim", " โ”‚ "), ("class:status-bar-dim", duration_label), diff --git a/tests/cli/test_cli_status_bar_goal.py b/tests/cli/test_cli_status_bar_goal.py new file mode 100644 index 00000000000..e8c947464fc --- /dev/null +++ b/tests/cli/test_cli_status_bar_goal.py @@ -0,0 +1,96 @@ +"""Status-bar goal segment (โŠ™ goal N/M) โ€” active-goal-only rendering. + +The segment mirrors the desktop composer goal indicator: it appears only +while a /goal is ACTIVE, shows turns used vs the turn budget, and stays out +of the bar entirely for paused/done/absent goals (those already print their +own glyph lines in the conversation thread). +""" + +from datetime import datetime, timedelta +from types import SimpleNamespace + +from cli import HermesCLI + + +def _make_cli(model: str = "anthropic/claude-sonnet-4-20250514"): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.model = model + cli_obj.session_start = datetime.now() - timedelta(minutes=14, seconds=32) + cli_obj.conversation_history = [{"role": "user", "content": "hi"}] + cli_obj.agent = None + return cli_obj + + +def _attach_goal(cli_obj, *, active: bool, turns_used: int = 3, max_turns: int = 20): + """Bind a fake GoalManager the way _get_goal_manager caches one.""" + cli_obj.session_id = "sess-goal-test" + cli_obj._goal_manager = SimpleNamespace( + session_id="sess-goal-test", + is_active=lambda: active, + state=SimpleNamespace(turns_used=turns_used, max_turns=max_turns), + ) + return cli_obj + + +class TestStatusBarGoalSegment: + def test_goal_segment_composition(self): + cli_obj = _attach_goal(_make_cli(), active=True, turns_used=3, max_turns=20) + + snapshot = cli_obj._get_status_bar_snapshot() + + assert snapshot["goal_active"] is True + assert snapshot["goal_turns_used"] == 3 + assert snapshot["goal_max_turns"] == 20 + assert cli_obj._status_bar_goal_segment(snapshot) == "โŠ™ goal 3/20" + + def test_goal_segment_absent_without_goal(self): + cli_obj = _make_cli() # no session_id โ†’ no goal manager + + snapshot = cli_obj._get_status_bar_snapshot() + + assert snapshot["goal_active"] is False + assert cli_obj._status_bar_goal_segment(snapshot) == "" + + def test_goal_segment_absent_when_paused(self): + # Paused goals must NOT occupy the status bar (active-only contract). + cli_obj = _attach_goal(_make_cli(), active=False) + + snapshot = cli_obj._get_status_bar_snapshot() + + assert snapshot["goal_active"] is False + assert cli_obj._status_bar_goal_segment(snapshot) == "" + + def test_goal_segment_without_budget_omits_counter(self): + segment = HermesCLI._status_bar_goal_segment( + {"goal_active": True, "goal_turns_used": 0, "goal_max_turns": 0} + ) + + assert segment == "โŠ™ goal" + + def test_active_goal_rendered_in_wide_status_bar(self): + cli_obj = _attach_goal(_make_cli(), active=True, turns_used=5, max_turns=20) + + text = cli_obj._build_status_bar_text(width=120) + + assert "โŠ™ goal 5/20" in text + + def test_active_goal_rendered_in_medium_status_bar(self): + cli_obj = _attach_goal(_make_cli(), active=True, turns_used=1, max_turns=20) + + text = cli_obj._build_status_bar_text(width=60) + + assert "โŠ™ goal 1/20" in text + + def test_active_goal_rendered_in_narrow_status_bar(self): + cli_obj = _attach_goal(_make_cli(), active=True, turns_used=2, max_turns=20) + + text = cli_obj._build_status_bar_text(width=50) + + assert "โŠ™ goal" in text + + def test_no_goal_segment_in_status_bar_without_goal(self): + cli_obj = _make_cli() + + text = cli_obj._build_status_bar_text(width=120) + + assert "โŠ™ goal" not in text