mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(cli): show active /goal segment in the TUI status bar
Append a "⊙ goal 3/20" segment (turns used / turn budget) to the CLI status bar whenever a standing /goal is active. Mirrors the desktop composer goal indicator: active-goal-only — paused/done goals stay out of the bar since they already print their own glyph lines in-thread. - Snapshot: goal_active / goal_turns_used / goal_max_turns from the cached GoalManager (in-memory attribute read, no DB hit per repaint). - Rendered in all three width tiers of both _build_status_bar_text and _get_status_bar_fragments, and it respects the /statusbar toggle for free (the toggle gates _get_status_bar_fragments as a whole). - Tests: segment composition, active-only contract, all width tiers. Status-bar goal indicator concept from #43020. Co-authored-by: Akshan Krithick <akshankrithick305@gmail.com> Assisted-by: Claude Fable 5 via Hermes Agent
This commit is contained in:
parent
e5d21e87cb
commit
e769560c76
2 changed files with 145 additions and 0 deletions
49
cli.py
49
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),
|
||||
|
|
|
|||
96
tests/cli/test_cli_status_bar_goal.py
Normal file
96
tests/cli/test_cli_status_bar_goal.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue