mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
Resolves the hermes_cli/cli_tui_mixin.py conflict from #141501 landing: main moved _Panel to hermes_cli/cli_panel.py and this branch moved the wrap helpers to hermes_cli/cli_tui_wrap.py. Keep both moves: import _Panel and the wrap helpers, define neither here. Refs #134889 (N8)
102 lines
4.7 KiB
Python
102 lines
4.7 KiB
Python
"""Goal, loop + queued-prompt rows for the classic CLI live-work dock.
|
|
|
|
Sibling of ``cli_process_dock``: the dock paints an active ``/goal``, a standing ``/loop`` with its
|
|
countdown, and the prompts waiting in ``/queue`` around the subagent and process blocks, so they are
|
|
visible while the agent works instead of only through ``/goal status``, ``/loop status`` and
|
|
``/queue list``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import time
|
|
|
|
# Queued prompts drain one per turn; past this many the dock shows a ``+N more`` line.
|
|
QUEUE_ROWS = 3
|
|
|
|
|
|
def sync_automation(cli) -> None:
|
|
"""Re-read the goal/loop managers' mirrored state once after the gateway flagged it changed mid-turn
|
|
(``_mark_automation_stale``): one ``state`` round trip per change, none while nothing moves."""
|
|
if not getattr(cli, "_automation_stale", False):
|
|
return
|
|
cli._automation_stale = False
|
|
for attr in ("_goal_manager", "_loop_manager"):
|
|
mgr = getattr(cli, attr, None)
|
|
if mgr is not None and mgr.session_id == getattr(cli, "session_id", None):
|
|
mgr.refresh()
|
|
|
|
|
|
def goal_line(cli) -> str:
|
|
"""``GoalManager.status_line()`` for an active or paused goal, else ``''``.
|
|
|
|
Reads only the manager the CLI already holds for its current session: the dock refreshes on
|
|
the spinner thread every second and must never be the thing that opens state.db.
|
|
"""
|
|
mgr = getattr(cli, "_goal_manager", None)
|
|
if mgr is None or mgr.session_id != getattr(cli, "session_id", None) or not mgr.has_goal():
|
|
return ""
|
|
return mgr.status_line()
|
|
|
|
|
|
def countdown(seconds: float) -> str:
|
|
"""Time left until the next wakeup, rounded UP: whole minutes until the last one, then seconds.
|
|
|
|
Minute steps keep an idle prompt's dock to one repaint a minute (the spinner thread repaints only
|
|
when the dock text changes); per-second repaints of a 45-minute wait would fight terminal viewport
|
|
restoration for nothing.
|
|
"""
|
|
left = max(0, math.ceil(seconds))
|
|
if left < 60:
|
|
return f"{left}s"
|
|
hours, minutes = divmod(math.ceil(left / 60), 60)
|
|
return f"{hours}h{minutes:02d}m" if hours else f"{minutes}m"
|
|
|
|
|
|
def loop_row(cli, now: float | None = None) -> tuple[str, str, str]:
|
|
"""``(badge, line, prompt)`` for an active or paused /loop, else ``('', '', '')``.
|
|
|
|
``line`` is the expanded dock row (``↻ loop · every 5m · 2 runs · next in 4m · <prompt>``),
|
|
``badge`` the collapsed heading's count fragment (``↻ loop next in 4m``). Same rule as :func:`goal_line`:
|
|
only the manager the CLI already holds is read, never state.db.
|
|
"""
|
|
mgr = getattr(cli, "_loop_manager", None)
|
|
# One read of ``state``: the input thread's ``/loop stop`` sets it to None under this spinner-thread poll.
|
|
s = mgr.state if mgr is not None and mgr.session_id == getattr(cli, "session_id", None) else None
|
|
if s is None or s.status not in {"active", "paused"}:
|
|
return "", "", ""
|
|
runs = f"{s.ticks_fired}/{s.times} runs" if s.times else f"{s.ticks_fired} run{'s' if s.ticks_fired != 1 else ''}"
|
|
if s.status == "paused":
|
|
reason = f" · {s.paused_reason}" if s.paused_reason else ""
|
|
return "⏸ loop paused", f"⏸ loop paused{reason} · {runs} · {s.prompt}", s.prompt
|
|
if s.awaiting_response:
|
|
when = "running now"
|
|
else:
|
|
remaining = s.next_due_at - (time.time() if now is None else now)
|
|
when = f"next in {countdown(remaining)}" if remaining > 0 else "due, waiting for idle"
|
|
return f"↻ loop {when}", f"↻ loop · {s.cadence_label()} · {runs} · {when} · {s.prompt}", s.prompt
|
|
|
|
|
|
def _prompt_text(item) -> str:
|
|
if isinstance(item, tuple): # (text, images) from an image-attached submit
|
|
item = item[0]
|
|
# TimelineNotification carries a compact title; voice/seeded sentinels wrap their text.
|
|
text = getattr(item, "display_text", None) or getattr(item, "text", None) or str(item)
|
|
return " ".join(str(text).split())
|
|
|
|
|
|
def queued_prompts(cli) -> list[str]:
|
|
"""One-line previews of the turns waiting in ``_pending_input``, oldest first."""
|
|
from hermes_cli.gateway_tools_notify import gateway_store
|
|
owned: list = []
|
|
store = gateway_store(cli) if getattr(cli, "session_id", None) else None
|
|
if store is not None:
|
|
# The gateway's FIFO runs first (#134889 N5): its queued admissions as the owner last published them.
|
|
from hermes_cli.gateway_queue import published_queue
|
|
owned = published_queue(cli, store)
|
|
pending = getattr(cli, "_pending_input", None)
|
|
if pending is None:
|
|
return [text for text in map(_prompt_text, owned) if text]
|
|
with pending.mutex:
|
|
items = list(pending.queue)
|
|
return [text for text in map(_prompt_text, owned + items) if text]
|