mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
Merge pull request #68860 from NousResearch/bb/battery-status
feat(status-bar): add /battery toggle for a color-coded battery read-out
This commit is contained in:
commit
413ed6b9df
19 changed files with 747 additions and 4 deletions
131
agent/battery.py
Normal file
131
agent/battery.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""System-battery read-out for the CLI/TUI status bar.
|
||||
|
||||
Reads the host battery through ``psutil`` (already a Hermes dependency) and
|
||||
exposes a compact, colour-coded label. Everything degrades to "unavailable"
|
||||
when there is no battery (desktops, servers, VMs) or when the read fails, so
|
||||
callers can render the result unconditionally and simply show nothing.
|
||||
|
||||
The status bar repaints often (every keystroke and on a ~1s idle refresh), so
|
||||
:func:`read_battery` memoises the last reading for a few seconds instead of
|
||||
hitting ``psutil`` on every frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatteryStatus:
|
||||
"""A single battery reading.
|
||||
|
||||
``available`` is False on machines without a battery (or when the read
|
||||
failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC
|
||||
power, False on battery, and None when the platform can't tell.
|
||||
"""
|
||||
|
||||
available: bool
|
||||
percent: Optional[int] = None
|
||||
plugged: Optional[bool] = None
|
||||
|
||||
@property
|
||||
def charging(self) -> bool:
|
||||
return bool(self.plugged)
|
||||
|
||||
|
||||
UNAVAILABLE = BatteryStatus(available=False)
|
||||
|
||||
# Colour buckets, mirroring the status-bar context styles but inverted (a full
|
||||
# battery is "good", an empty one is "critical").
|
||||
CATEGORY_GOOD = "good"
|
||||
CATEGORY_WARN = "warn"
|
||||
CATEGORY_BAD = "bad"
|
||||
CATEGORY_CRITICAL = "critical"
|
||||
CATEGORY_DIM = "dim"
|
||||
|
||||
_CACHE_TTL_SECONDS = 8.0
|
||||
_cache: Optional[tuple[float, BatteryStatus]] = None
|
||||
|
||||
|
||||
def _read_battery_uncached() -> BatteryStatus:
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
return UNAVAILABLE
|
||||
|
||||
# ``sensors_battery`` is missing on some platforms/builds of psutil.
|
||||
reader = getattr(psutil, "sensors_battery", None)
|
||||
if reader is None:
|
||||
return UNAVAILABLE
|
||||
|
||||
try:
|
||||
batt = reader()
|
||||
except Exception:
|
||||
return UNAVAILABLE
|
||||
|
||||
if batt is None:
|
||||
return UNAVAILABLE
|
||||
|
||||
percent: Optional[int] = None
|
||||
raw_percent = getattr(batt, "percent", None)
|
||||
if raw_percent is not None:
|
||||
try:
|
||||
percent = max(0, min(100, int(round(float(raw_percent)))))
|
||||
except (TypeError, ValueError):
|
||||
percent = None
|
||||
|
||||
plugged = getattr(batt, "power_plugged", None)
|
||||
if plugged is not None:
|
||||
plugged = bool(plugged)
|
||||
|
||||
return BatteryStatus(available=True, percent=percent, plugged=plugged)
|
||||
|
||||
|
||||
def read_battery(use_cache: bool = True) -> BatteryStatus:
|
||||
"""Return the current battery status (cached for a few seconds)."""
|
||||
global _cache
|
||||
if use_cache and _cache is not None:
|
||||
ts, cached = _cache
|
||||
if time.monotonic() - ts < _CACHE_TTL_SECONDS:
|
||||
return cached
|
||||
|
||||
status = _read_battery_uncached()
|
||||
_cache = (time.monotonic(), status)
|
||||
return status
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the memoised reading (used by tests)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def battery_category(status: BatteryStatus) -> str:
|
||||
"""Bucket a reading into a colour category: good/warn/bad/critical/dim."""
|
||||
if not status.available or status.percent is None:
|
||||
return CATEGORY_DIM
|
||||
# On AC power the level isn't a concern — always read as healthy.
|
||||
if status.charging:
|
||||
return CATEGORY_GOOD
|
||||
pct = status.percent
|
||||
if pct <= 10:
|
||||
return CATEGORY_CRITICAL
|
||||
if pct <= 20:
|
||||
return CATEGORY_BAD
|
||||
if pct <= 50:
|
||||
return CATEGORY_WARN
|
||||
return CATEGORY_GOOD
|
||||
|
||||
|
||||
def battery_glyph(status: BatteryStatus) -> str:
|
||||
"""Return the leading glyph: a bolt while charging, else a battery."""
|
||||
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
|
||||
|
||||
|
||||
def format_battery(status: BatteryStatus) -> str:
|
||||
"""Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A)."""
|
||||
if not status.available or status.percent is None:
|
||||
return ""
|
||||
return f"{battery_glyph(status)} {status.percent}%"
|
||||
110
cli.py
110
cli.py
|
|
@ -4198,6 +4198,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
|
||||
# Status bar visibility (toggled via /statusbar)
|
||||
self._status_bar_visible = True
|
||||
# Battery read-out in the status bar (toggled via /battery, off by
|
||||
# default). Persisted to display.battery so it survives restarts.
|
||||
self._battery_visible = bool(CLI_CONFIG["display"].get("battery", False))
|
||||
# When True, the input separator rules and the dynamic status bar are
|
||||
# hidden until the next user input. Set by _recover_after_resize() so a
|
||||
# SIGWINCH cannot stamp a freshly-drawn status bar on top of one that
|
||||
|
|
@ -4555,6 +4558,73 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
return "class:status-bar-warn"
|
||||
return "class:status-bar-good"
|
||||
|
||||
@staticmethod
|
||||
def _battery_status_style(category: str) -> str:
|
||||
"""Map a battery colour category to a status-bar style class."""
|
||||
return {
|
||||
"good": "class:status-bar-good",
|
||||
"warn": "class:status-bar-warn",
|
||||
"bad": "class:status-bar-bad",
|
||||
"critical": "class:status-bar-critical",
|
||||
}.get(category, "class:status-bar-dim")
|
||||
|
||||
def _handle_battery_command(self, cmd_original: str) -> None:
|
||||
"""Toggle the status-bar battery read-out.
|
||||
|
||||
``/battery`` toggles, ``/battery on|off`` sets explicitly, and
|
||||
``/battery status`` reports the current setting plus a live reading.
|
||||
The choice is persisted to ``display.battery`` so it survives restarts.
|
||||
"""
|
||||
parts = (cmd_original or "").split()
|
||||
arg = parts[1].strip().lower() if len(parts) > 1 else ""
|
||||
|
||||
try:
|
||||
from agent.battery import format_battery, read_battery
|
||||
reading = read_battery(use_cache=False)
|
||||
except Exception:
|
||||
reading = None
|
||||
|
||||
if arg in ("status", "show"):
|
||||
state = "on" if self._battery_visible else "off"
|
||||
if reading is not None and reading.available:
|
||||
self._console_print(
|
||||
f" Battery indicator {state} — currently {format_battery(reading)}"
|
||||
)
|
||||
elif reading is not None:
|
||||
self._console_print(
|
||||
f" Battery indicator {state} — no battery detected on this machine"
|
||||
)
|
||||
else:
|
||||
self._console_print(f" Battery indicator {state}")
|
||||
return
|
||||
|
||||
if arg in ("on", "true", "yes"):
|
||||
target = True
|
||||
elif arg in ("off", "false", "no"):
|
||||
target = False
|
||||
elif arg in ("", "toggle"):
|
||||
target = not self._battery_visible
|
||||
else:
|
||||
self._console_print(" Usage: /battery [on|off|status]")
|
||||
return
|
||||
|
||||
self._battery_visible = target
|
||||
save_config_value("display.battery", target)
|
||||
|
||||
if target:
|
||||
if reading is not None and not reading.available:
|
||||
self._console_print(
|
||||
" Battery indicator on — no battery detected, so nothing will show here"
|
||||
)
|
||||
elif reading is not None and reading.available:
|
||||
self._console_print(
|
||||
f" Battery indicator on — {format_battery(reading)}"
|
||||
)
|
||||
else:
|
||||
self._console_print(" Battery indicator on")
|
||||
else:
|
||||
self._console_print(" Battery indicator off")
|
||||
|
||||
@staticmethod
|
||||
def _compression_count_style(count: int) -> str:
|
||||
"""Return a style class reflecting context compression pressure."""
|
||||
|
|
@ -4672,8 +4742,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
"active_background_tasks": 0,
|
||||
"active_background_processes": 0,
|
||||
"active_background_subagents": 0,
|
||||
"battery_label": "",
|
||||
"battery_category": "dim",
|
||||
}
|
||||
|
||||
# Battery read-out (first status-bar element when enabled). Reads are
|
||||
# memoised for a few seconds inside agent.battery, so polling it on
|
||||
# every status-bar repaint is cheap.
|
||||
if getattr(self, "_battery_visible", False):
|
||||
try:
|
||||
from agent.battery import (
|
||||
battery_category,
|
||||
format_battery,
|
||||
read_battery,
|
||||
)
|
||||
|
||||
_batt = read_battery()
|
||||
snapshot["battery_label"] = format_battery(_batt)
|
||||
snapshot["battery_category"] = battery_category(_batt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Count live /background tasks. The dict entry is removed in the
|
||||
# task thread's finally block, so len() reflects truly-running tasks.
|
||||
# len() on a CPython dict is atomic; safe to read without a lock.
|
||||
|
|
@ -5153,15 +5242,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
percent = snapshot["context_percent"]
|
||||
percent_label = f"{percent}%" if percent is not None else "--"
|
||||
duration_label = snapshot["duration"]
|
||||
battery_label = snapshot.get("battery_label") or ""
|
||||
battery_prefix = f"{battery_label} │ " if battery_label else ""
|
||||
|
||||
yolo_active = self._is_session_yolo_active()
|
||||
if width < 52:
|
||||
text = f"⚕ {snapshot['model_short']} · {duration_label}"
|
||||
text = f"{battery_prefix}⚕ {snapshot['model_short']} · {duration_label}"
|
||||
if yolo_active:
|
||||
text += " · ⚠ YOLO"
|
||||
return self._trim_status_bar_text(text, width)
|
||||
if width < 76:
|
||||
parts = [f"⚕ {snapshot['model_short']}", percent_label]
|
||||
if battery_label:
|
||||
parts.insert(0, battery_label)
|
||||
compressions = snapshot.get("compressions", 0)
|
||||
if compressions:
|
||||
parts.append(f"🗜️ {compressions}")
|
||||
|
|
@ -5188,6 +5281,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
|
||||
compressions = snapshot.get("compressions", 0)
|
||||
parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label]
|
||||
if battery_label:
|
||||
parts.insert(0, battery_label)
|
||||
if compressions:
|
||||
parts.append(f"🗜️ {compressions}")
|
||||
bg_count = snapshot.get("active_background_tasks", 0)
|
||||
|
|
@ -5225,6 +5320,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
width = self._get_tui_terminal_width()
|
||||
duration_label = snapshot["duration"]
|
||||
yolo_active = self._is_session_yolo_active()
|
||||
battery_label = snapshot.get("battery_label") or ""
|
||||
battery_style = self._battery_status_style(snapshot.get("battery_category", "dim"))
|
||||
|
||||
if width < 52:
|
||||
frags = [
|
||||
|
|
@ -5325,6 +5422,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
|
||||
frags.append(("class:status-bar", " "))
|
||||
|
||||
# Battery is the first status-bar element when enabled: prepend it
|
||||
# ahead of the leading ⚕ marker in whichever width tier ran above.
|
||||
if battery_label:
|
||||
frags[0:0] = [
|
||||
("class:status-bar", " "),
|
||||
(battery_style, battery_label),
|
||||
("class:status-bar-dim", " │"),
|
||||
]
|
||||
|
||||
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
|
||||
if total_width > width:
|
||||
plain_text = "".join(text for _, text in frags)
|
||||
|
|
@ -8978,6 +9084,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
self._status_bar_visible = not self._status_bar_visible
|
||||
state = "visible" if self._status_bar_visible else "hidden"
|
||||
self._console_print(f" Status bar {state}")
|
||||
elif canonical == "battery":
|
||||
self._handle_battery_command(cmd_original)
|
||||
elif canonical == "timestamps":
|
||||
self._handle_timestamps_command(cmd_original)
|
||||
elif canonical == "verbose":
|
||||
|
|
|
|||
|
|
@ -141,6 +141,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
args_hint="[name]"),
|
||||
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
|
||||
cli_only=True, aliases=("sb",)),
|
||||
CommandDef("battery", "Toggle a color-coded battery indicator in the status bar",
|
||||
"Configuration", cli_only=True, args_hint="[on|off|status]",
|
||||
subcommands=("on", "off", "status")),
|
||||
CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration",
|
||||
cli_only=True, args_hint="[on|off|status]",
|
||||
subcommands=("on", "off", "status"), aliases=("ts",)),
|
||||
|
|
|
|||
|
|
@ -1927,6 +1927,9 @@ DEFAULT_CONFIG = {
|
|||
# failure isn't silent from the UI's perspective. Set false to suppress.
|
||||
"turn_completion_explainer": True,
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
# Show a color-coded battery read-out as the first status-bar element in
|
||||
# the CLI/TUI (off by default). No-op on machines without a battery.
|
||||
"battery": False,
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
# handful of gateway slash-command replies). Does NOT affect agent
|
||||
|
|
|
|||
115
tests/agent/test_battery.py
Normal file
115
tests/agent/test_battery.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Behavior tests for the status-bar battery helper (agent/battery.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import battery as battery_mod
|
||||
from agent.battery import (
|
||||
BatteryStatus,
|
||||
battery_category,
|
||||
battery_glyph,
|
||||
format_battery,
|
||||
read_battery,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
battery_mod.clear_cache()
|
||||
yield
|
||||
battery_mod.clear_cache()
|
||||
|
||||
|
||||
def _fake_psutil(percent, plugged):
|
||||
"""Install a fake psutil module whose sensors_battery returns a reading."""
|
||||
mod = types.ModuleType("psutil")
|
||||
reading = types.SimpleNamespace(percent=percent, power_plugged=plugged)
|
||||
mod.sensors_battery = lambda: reading # type: ignore[attr-defined]
|
||||
return mod
|
||||
|
||||
|
||||
def test_read_battery_no_psutil(monkeypatch):
|
||||
# Force the import inside read_battery to fail.
|
||||
monkeypatch.setitem(sys.modules, "psutil", None)
|
||||
status = read_battery(use_cache=False)
|
||||
assert status.available is False
|
||||
assert status.percent is None
|
||||
|
||||
|
||||
def test_read_battery_no_battery(monkeypatch):
|
||||
mod = types.ModuleType("psutil")
|
||||
mod.sensors_battery = lambda: None # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "psutil", mod)
|
||||
|
||||
status = read_battery(use_cache=False)
|
||||
assert status.available is False
|
||||
|
||||
|
||||
def test_read_battery_reads_and_clamps(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil(87.6, False))
|
||||
status = read_battery(use_cache=False)
|
||||
assert status.available is True
|
||||
assert status.percent == 88 # rounded
|
||||
assert status.plugged is False
|
||||
|
||||
|
||||
def test_read_battery_clamps_out_of_range(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil(150, True))
|
||||
status = read_battery(use_cache=False)
|
||||
assert status.percent == 100
|
||||
assert status.plugged is True
|
||||
|
||||
|
||||
def test_read_battery_caches(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil(50, False))
|
||||
first = read_battery(use_cache=True)
|
||||
assert first.percent == 50
|
||||
|
||||
# Swap the reading; a cached call must still return the first value.
|
||||
monkeypatch.setitem(sys.modules, "psutil", _fake_psutil(10, True))
|
||||
cached = read_battery(use_cache=True)
|
||||
assert cached.percent == 50
|
||||
|
||||
# Bypassing the cache picks up the new reading.
|
||||
fresh = read_battery(use_cache=False)
|
||||
assert fresh.percent == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"percent,plugged,expected",
|
||||
[
|
||||
(100, False, "good"),
|
||||
(51, False, "good"),
|
||||
(50, False, "warn"),
|
||||
(21, False, "warn"),
|
||||
(20, False, "bad"),
|
||||
(11, False, "bad"),
|
||||
(10, False, "critical"),
|
||||
(1, False, "critical"),
|
||||
# On AC power the level never reads as low.
|
||||
(5, True, "good"),
|
||||
],
|
||||
)
|
||||
def test_battery_category_thresholds(percent, plugged, expected):
|
||||
status = BatteryStatus(available=True, percent=percent, plugged=plugged)
|
||||
assert battery_category(status) == expected
|
||||
|
||||
|
||||
def test_battery_category_unavailable_is_dim():
|
||||
assert battery_category(BatteryStatus(available=False)) == "dim"
|
||||
assert battery_category(BatteryStatus(available=True, percent=None)) == "dim"
|
||||
|
||||
|
||||
def test_format_and_glyph():
|
||||
on_battery = BatteryStatus(available=True, percent=82, plugged=False)
|
||||
charging = BatteryStatus(available=True, percent=82, plugged=True)
|
||||
|
||||
assert battery_glyph(on_battery) == "\U0001f50b" # 🔋
|
||||
assert battery_glyph(charging) == "\u26a1" # ⚡
|
||||
assert format_battery(on_battery) == "\U0001f50b 82%"
|
||||
assert format_battery(charging) == "\u26a1 82%"
|
||||
assert format_battery(BatteryStatus(available=False)) == ""
|
||||
|
|
@ -885,6 +885,78 @@ def test_dispatch_rejects_non_object_params():
|
|||
}
|
||||
|
||||
|
||||
def test_system_battery_returns_reading(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"agent.battery",
|
||||
types.SimpleNamespace(
|
||||
read_battery=lambda: types.SimpleNamespace(
|
||||
available=True, percent=77, plugged=False
|
||||
),
|
||||
battery_category=lambda _s: "good",
|
||||
),
|
||||
)
|
||||
|
||||
resp = server.dispatch({"id": "b1", "method": "system.battery", "params": {}})
|
||||
|
||||
assert resp["result"] == {
|
||||
"available": True,
|
||||
"percent": 77,
|
||||
"plugged": False,
|
||||
"category": "good",
|
||||
}
|
||||
|
||||
|
||||
def test_system_battery_fails_open(monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("no battery subsystem")
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"agent.battery",
|
||||
types.SimpleNamespace(read_battery=boom, battery_category=lambda _s: "dim"),
|
||||
)
|
||||
|
||||
resp = server.dispatch({"id": "b2", "method": "system.battery", "params": {}})
|
||||
|
||||
assert resp["result"]["available"] is False
|
||||
assert resp["result"]["percent"] is None
|
||||
|
||||
|
||||
def test_config_set_battery_toggles_and_persists(monkeypatch):
|
||||
writes: dict[str, object] = {}
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {"battery": False}})
|
||||
monkeypatch.setattr(
|
||||
server, "_write_config_key", lambda k, v: writes.__setitem__(k, v)
|
||||
)
|
||||
|
||||
resp = server.dispatch(
|
||||
{"id": "c1", "method": "config.set", "params": {"key": "battery", "value": ""}}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"key": "battery", "value": "on"}
|
||||
assert writes == {"display.battery": True}
|
||||
|
||||
|
||||
def test_config_set_battery_explicit_off(monkeypatch):
|
||||
writes: dict[str, object] = {}
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {"battery": True}})
|
||||
monkeypatch.setattr(
|
||||
server, "_write_config_key", lambda k, v: writes.__setitem__(k, v)
|
||||
)
|
||||
|
||||
resp = server.dispatch(
|
||||
{
|
||||
"id": "c2",
|
||||
"method": "config.set",
|
||||
"params": {"key": "battery", "value": "off"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"key": "battery", "value": "off"}
|
||||
assert writes == {"display.battery": False}
|
||||
|
||||
|
||||
def test_voice_toggle_returns_configured_record_key(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
|
|
|
|||
|
|
@ -11788,6 +11788,22 @@ def _(rid, params: dict) -> dict:
|
|||
_write_config_key("display.tui_compact", nv_b)
|
||||
return _ok(rid, {"key": key, "value": "on" if nv_b else "off"})
|
||||
|
||||
if key == "battery":
|
||||
raw = str(value or "").strip().lower()
|
||||
cfg0 = _load_cfg()
|
||||
d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {}
|
||||
cur_b = bool(d0.get("battery", False))
|
||||
if raw in {"", "toggle"}:
|
||||
nv_b = not cur_b
|
||||
elif raw in {"on", "true", "yes"}:
|
||||
nv_b = True
|
||||
elif raw in {"off", "false", "no"}:
|
||||
nv_b = False
|
||||
else:
|
||||
return _err(rid, 4002, f"unknown battery value: {value}")
|
||||
_write_config_key("display.battery", nv_b)
|
||||
return _ok(rid, {"key": key, "value": "on" if nv_b else "off"})
|
||||
|
||||
if key == "statusbar":
|
||||
raw = str(value or "").strip().lower()
|
||||
display = _load_cfg().get("display")
|
||||
|
|
@ -12732,6 +12748,31 @@ def _(rid, params: dict) -> dict:
|
|||
# ── Methods: tools & system ──────────────────────────────────────────
|
||||
|
||||
|
||||
@method("system.battery")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Return the host battery status for the status-bar read-out.
|
||||
|
||||
Always resolves with a payload; ``available: false`` means there is no
|
||||
battery (desktop/server/VM) or the read failed. The TUI only polls this
|
||||
while the battery indicator is enabled.
|
||||
"""
|
||||
try:
|
||||
from agent.battery import battery_category, read_battery
|
||||
|
||||
batt = read_battery()
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"available": batt.available,
|
||||
"percent": batt.percent,
|
||||
"plugged": batt.plugged,
|
||||
"category": battery_category(batt),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
return _ok(rid, {"available": False, "percent": None, "plugged": None, "category": "dim"})
|
||||
|
||||
|
||||
@method("process.stop")
|
||||
def _(rid, params: dict) -> dict:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -353,6 +353,52 @@ describe('StatusRule credits notice render priority', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('StatusRule battery indicator', () => {
|
||||
it('renders the battery label with a battery glyph on AC-off', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
battery: { available: true, category: 'good', percent: 82, plugged: false }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('🔋 82%')
|
||||
})
|
||||
|
||||
it('uses a bolt glyph while charging', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
battery: { available: true, category: 'good', percent: 82, plugged: true }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('⚡ 82%')
|
||||
})
|
||||
|
||||
it('colours the read-out by category (critical → theme statusCritical)', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
battery: { available: true, category: 'critical', percent: 7, plugged: false }
|
||||
})
|
||||
|
||||
const leaf = findElementWithText(element, '7%')
|
||||
expect(leaf?.props.color).toBe(DEFAULT_THEME.color.statusCritical)
|
||||
})
|
||||
|
||||
it('omits the segment when battery is null', () => {
|
||||
const element = StatusRule({ ...baseProps, battery: null })
|
||||
|
||||
expect(textContent(element)).not.toContain('%🔋')
|
||||
expect(textContent(element)).not.toContain('🔋')
|
||||
})
|
||||
|
||||
it('omits the segment when no battery is available (desktop/server)', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
battery: { available: false, category: 'dim', percent: null, plugged: null }
|
||||
})
|
||||
|
||||
expect(textContent(element)).not.toContain('🔋')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule idle-since read-out', () => {
|
||||
// The IdleSince component uses hooks, so it can't be invoked outside a
|
||||
// renderer — assert on the element tree instead (same reason the duration
|
||||
|
|
|
|||
36
ui-tui/src/__tests__/useBatteryPoll.test.ts
Normal file
36
ui-tui/src/__tests__/useBatteryPoll.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toBatteryInfo } from '../app/useBatteryPoll.js'
|
||||
|
||||
describe('toBatteryInfo', () => {
|
||||
it('returns null for a null payload', () => {
|
||||
expect(toBatteryInfo(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('maps a full reading through faithfully', () => {
|
||||
expect(toBatteryInfo({ available: true, category: 'warn', percent: 44, plugged: false })).toEqual({
|
||||
available: true,
|
||||
category: 'warn',
|
||||
percent: 44,
|
||||
plugged: false
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps and rounds the percent into 0-100', () => {
|
||||
expect(toBatteryInfo({ available: true, category: 'good', percent: 142.7, plugged: true })?.percent).toBe(100)
|
||||
expect(toBatteryInfo({ available: true, category: 'critical', percent: -5, plugged: false })?.percent).toBe(0)
|
||||
expect(toBatteryInfo({ available: true, category: 'warn', percent: 43.4, plugged: false })?.percent).toBe(43)
|
||||
})
|
||||
|
||||
it('coerces a missing/invalid percent to null', () => {
|
||||
expect(toBatteryInfo({ available: true, category: 'dim' })?.percent).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the dim category for an unknown value', () => {
|
||||
expect(toBatteryInfo({ available: true, category: 'purple', percent: 50, plugged: false })?.category).toBe('dim')
|
||||
})
|
||||
|
||||
it('treats a non-boolean plugged as unknown (null)', () => {
|
||||
expect(toBatteryInfo({ available: false, category: 'dim', percent: null })?.plugged).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -37,6 +37,17 @@ export interface StateSetter<T> {
|
|||
|
||||
export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
|
||||
export type BatteryCategory = 'bad' | 'critical' | 'dim' | 'good' | 'warn'
|
||||
|
||||
// A single battery reading pushed from the Python gateway (`system.battery`).
|
||||
// `available` is false on machines without a battery; `percent` is 0-100.
|
||||
export interface BatteryInfo {
|
||||
available: boolean
|
||||
category: BatteryCategory
|
||||
percent: null | number
|
||||
plugged: null | boolean
|
||||
}
|
||||
|
||||
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
||||
|
||||
export type NoticeLevel = 'error' | 'info' | 'success' | 'warn'
|
||||
|
|
@ -297,6 +308,8 @@ export interface TranscriptRow {
|
|||
}
|
||||
|
||||
export interface UiState {
|
||||
battery: boolean
|
||||
batteryStatus: BatteryInfo | null
|
||||
bgTasks: Set<string>
|
||||
busy: boolean
|
||||
busyInputMode: BusyInputMode
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import type {
|
|||
SessionStatusResponse,
|
||||
SessionSteerResponse,
|
||||
SessionTitleResponse,
|
||||
SessionUndoResponse
|
||||
SessionUndoResponse,
|
||||
SystemBatteryResponse
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { writeClipboardText } from '../../../lib/clipboard.js'
|
||||
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
|
||||
|
|
@ -579,6 +580,45 @@ export const coreCommands: SlashCommand[] = [
|
|||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'toggle a color-coded battery indicator in the status bar [on|off|status]',
|
||||
name: 'battery',
|
||||
run: (arg, ctx) => {
|
||||
const mode = arg.trim().toLowerCase()
|
||||
|
||||
// `/battery status` reports the current setting plus a live reading,
|
||||
// matching the CLI surface. Fetch on demand so it works even while the
|
||||
// indicator (and its poller) is off.
|
||||
if (mode === 'status' || mode === 'show') {
|
||||
const state = ctx.ui.battery ? 'on' : 'off'
|
||||
|
||||
ctx.gateway
|
||||
.rpc<SystemBatteryResponse>('system.battery', {})
|
||||
.then(r => {
|
||||
if (r?.available && typeof r.percent === 'number') {
|
||||
ctx.transcript.sys(`battery indicator ${state} — currently ${r.plugged ? '⚡' : '🔋'} ${r.percent}%`)
|
||||
} else {
|
||||
ctx.transcript.sys(`battery indicator ${state} — no battery detected on this machine`)
|
||||
}
|
||||
})
|
||||
.catch(() => ctx.transcript.sys(`battery indicator ${state}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const next = flagFromArg(arg, ctx.ui.battery)
|
||||
|
||||
if (next === null) {
|
||||
return ctx.transcript.sys('usage: /battery [on|off|status]')
|
||||
}
|
||||
|
||||
patchUiState({ battery: next, ...(next ? {} : { batteryStatus: null }) })
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'battery', value: next ? 'on' : 'off' }).catch(() => {})
|
||||
|
||||
queueMicrotask(() => ctx.transcript.sys(`battery indicator ${next ? 'on' : 'off'}`))
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
aliases: ['q'],
|
||||
help: 'inspect or enqueue a message',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { DEFAULT_THEME } from '../theme.js'
|
|||
import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
|
||||
|
||||
const buildUiState = (): UiState => ({
|
||||
battery: false,
|
||||
batteryStatus: null,
|
||||
bgTasks: new Set(),
|
||||
busy: false,
|
||||
busyInputMode: 'queue',
|
||||
|
|
|
|||
77
ui-tui/src/app/useBatteryPoll.ts
Normal file
77
ui-tui/src/app/useBatteryPoll.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { SystemBatteryResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
||||
import type { BatteryCategory, BatteryInfo } from './interfaces.js'
|
||||
import { $uiState, patchUiState } from './uiStore.js'
|
||||
|
||||
const BATTERY_POLL_MS = 30_000
|
||||
|
||||
const CATEGORIES: ReadonlySet<BatteryCategory> = new Set(['bad', 'critical', 'dim', 'good', 'warn'])
|
||||
|
||||
const normalizeCategory = (raw: unknown): BatteryCategory =>
|
||||
typeof raw === 'string' && CATEGORIES.has(raw as BatteryCategory) ? (raw as BatteryCategory) : 'dim'
|
||||
|
||||
/** Coerce a `system.battery` RPC payload into the UI's BatteryInfo shape. */
|
||||
export const toBatteryInfo = (r: null | SystemBatteryResponse): BatteryInfo | null => {
|
||||
if (!r) {
|
||||
return null
|
||||
}
|
||||
|
||||
const percent =
|
||||
typeof r.percent === 'number' && Number.isFinite(r.percent)
|
||||
? Math.max(0, Math.min(100, Math.round(r.percent)))
|
||||
: null
|
||||
|
||||
return {
|
||||
available: !!r.available,
|
||||
category: normalizeCategory(r.category),
|
||||
percent,
|
||||
plugged: typeof r.plugged === 'boolean' ? r.plugged : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the host battery while the status-bar indicator is enabled.
|
||||
*
|
||||
* The reading is a system property (not per-session), so this runs whenever
|
||||
* `display.battery` is on — no `sid` gate. Python memoises the read, so a
|
||||
* 30s cadence is plenty to keep the read-out fresh without churn. When the
|
||||
* indicator is toggled off the cached reading is cleared.
|
||||
*/
|
||||
export function useBatteryPoll(gw: GatewayClient) {
|
||||
const enabled = useStore($uiState).battery
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
patchUiState({ batteryStatus: null })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = asRpcResult<SystemBatteryResponse>(await gw.request<SystemBatteryResponse>('system.battery', {}))
|
||||
|
||||
if (!cancelled) {
|
||||
patchUiState({ batteryStatus: toBatteryInfo(r) })
|
||||
}
|
||||
} catch {
|
||||
// Keep the last-good reading on a transient RPC failure.
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
const id = setInterval(() => void poll(), BATTERY_POLL_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [enabled, gw])
|
||||
}
|
||||
|
|
@ -217,6 +217,7 @@ export const applyDisplay = (
|
|||
}
|
||||
|
||||
patchUiState({
|
||||
battery: !!d.battery,
|
||||
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
|
||||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import { scrollWithSelectionBy } from './scroll.js'
|
|||
import { turnController } from './turnController.js'
|
||||
import { patchTurnState, useTurnSelector } from './turnStore.js'
|
||||
import { $uiState, getUiState, patchUiState } from './uiStore.js'
|
||||
import { useBatteryPoll } from './useBatteryPoll.js'
|
||||
import { useComposerState } from './useComposerState.js'
|
||||
import { useConfigSync } from './useConfigSync.js'
|
||||
import { useInputHandlers } from './useInputHandlers.js'
|
||||
|
|
@ -537,6 +538,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
}, [ui.busy, turnStartedAt])
|
||||
|
||||
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
|
||||
useBatteryPoll(gw)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.sid) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState }
|
|||
import unicodeSpinners from 'unicode-animations'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
import type { IndicatorStyle, Notice } from '../app/interfaces.js'
|
||||
import type { BatteryInfo, IndicatorStyle, Notice } from '../app/interfaces.js'
|
||||
import { useTurnSelector } from '../app/turnStore.js'
|
||||
import { DEV_CREDITS_MODE } from '../config/env.js'
|
||||
import { FACES } from '../content/faces.js'
|
||||
|
|
@ -186,6 +186,34 @@ function statusSessionCountLabel(count: number) {
|
|||
return `${count} ${count === 1 ? 'session' : 'sessions'}`
|
||||
}
|
||||
|
||||
// Colour the battery read-out by its (Python-computed) category. Inverted vs
|
||||
// the context bar — a full battery is "good", an empty one "critical".
|
||||
function batteryColor(info: BatteryInfo, t: Theme): string {
|
||||
if (info.category === 'good') {
|
||||
return t.color.statusGood
|
||||
}
|
||||
|
||||
if (info.category === 'warn') {
|
||||
return t.color.statusWarn
|
||||
}
|
||||
|
||||
if (info.category === 'bad') {
|
||||
return t.color.statusBad
|
||||
}
|
||||
|
||||
if (info.category === 'critical') {
|
||||
return t.color.statusCritical
|
||||
}
|
||||
|
||||
return t.color.muted
|
||||
}
|
||||
|
||||
// Compact battery label: a bolt while charging, else a battery glyph.
|
||||
// Renders `--` for an unknown percent so a null can never surface as "null%".
|
||||
function batteryLabel(info: BatteryInfo): string {
|
||||
return `${info.plugged ? '⚡' : '🔋'} ${info.percent ?? '--'}%`
|
||||
}
|
||||
|
||||
// Colour a credits notice by its level. The notice TEXT already carries its
|
||||
// own glyph (⚠ • ✕ ✓) from the Python policy — we only tint it here, never
|
||||
// prepend another glyph. `success` maps to the theme's green status colour.
|
||||
|
|
@ -403,6 +431,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
|||
}
|
||||
|
||||
export function StatusRule({
|
||||
battery,
|
||||
cwdLabel,
|
||||
cols,
|
||||
busy,
|
||||
|
|
@ -440,6 +469,12 @@ export function StatusRule({
|
|||
const bar = !segs.compactCtx && usage.context_max ? ctxBar(pct) : ''
|
||||
const modelText = modelLabel(model, modelReasoningEffort, modelFast)
|
||||
|
||||
// Battery read-out — the first (pinned) status-bar element when enabled.
|
||||
const showBattery = !!battery && battery.available && battery.percent != null
|
||||
const batteryText = showBattery ? batteryLabel(battery!) : ''
|
||||
const batteryColorVal = showBattery ? batteryColor(battery!, t) : ''
|
||||
const batteryWidth = showBattery ? stringWidth(`${batteryText} │ `) : 0
|
||||
|
||||
// A credits notice replaces the status/verb slot, but only when idle —
|
||||
// while busy the FaceTicker always wins (R1 render priority). The notice
|
||||
// text carries its own glyph; we only tint it (R1) and let it shrink (R3-M7).
|
||||
|
|
@ -465,6 +500,7 @@ export function StatusRule({
|
|||
|
||||
const essentialWidth =
|
||||
stringWidth('─ ') +
|
||||
batteryWidth +
|
||||
slotWidth +
|
||||
stringWidth(' │ ') +
|
||||
stringWidth(modelText) +
|
||||
|
|
@ -554,6 +590,12 @@ export function StatusRule({
|
|||
ellipsizes instead of crushing model │ ctx (R3-M7). */}
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
<Text color={t.color.border}>{'─ '}</Text>
|
||||
{showBattery ? (
|
||||
<Text color={batteryColorVal}>
|
||||
{batteryText}
|
||||
<Text color={t.color.muted}>{' │ '}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{busy ? (
|
||||
<FaceTicker color={statusColor} startedAt={turnStartedAt} style={indicatorStyle} />
|
||||
) : showNotice ? null : (
|
||||
|
|
@ -770,6 +812,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
|
|||
}
|
||||
|
||||
interface StatusRuleProps {
|
||||
battery?: BatteryInfo | null
|
||||
bgCount: number
|
||||
lastTurnEndedAt?: null | number
|
||||
liveSessionCount: number
|
||||
|
|
|
|||
|
|
@ -473,6 +473,7 @@ const StatusRulePane = memo(function StatusRulePane({
|
|||
return (
|
||||
<Box marginTop={at === 'top' ? 1 : 0}>
|
||||
<StatusRule
|
||||
battery={ui.battery ? ui.batteryStatus : null}
|
||||
bgCount={ui.bgTasks.size}
|
||||
busy={ui.busy}
|
||||
cols={composer.cols}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export type CommandDispatchResponse =
|
|||
// ── Config ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ConfigDisplayConfig {
|
||||
battery?: boolean
|
||||
bell_on_complete?: boolean
|
||||
busy_input_mode?: string
|
||||
details_mode?: string
|
||||
|
|
@ -143,6 +144,13 @@ export interface SetupStatusResponse {
|
|||
provider_configured?: boolean
|
||||
}
|
||||
|
||||
export interface SystemBatteryResponse {
|
||||
available?: boolean
|
||||
category?: string
|
||||
percent?: null | number
|
||||
plugged?: null | boolean
|
||||
}
|
||||
|
||||
// ── Session lifecycle ────────────────────────────────────────────────
|
||||
|
||||
export interface SessionCreateResponse {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
|||
| `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) |
|
||||
| `/skin` | Show or change the display skin/theme |
|
||||
| `/statusbar` (alias: `/sb`) | Toggle the context/model status bar on or off |
|
||||
| `/battery [on\|off\|status]` | Toggle a color-coded battery read-out as the first status-bar element (off by default; no-op without a battery). |
|
||||
| `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). |
|
||||
| `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. |
|
||||
| `/footer [on\|off\|status]` | Toggle the gateway runtime-metadata footer on final replies (shows model, context %, and cwd). |
|
||||
|
|
@ -250,7 +251,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||
|
||||
## Notes
|
||||
|
||||
- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.
|
||||
- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.
|
||||
- `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces.
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue