diff --git a/agent/battery.py b/agent/battery.py new file mode 100644 index 000000000000..a1c0f32fa4d1 --- /dev/null +++ b/agent/battery.py @@ -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}%" diff --git a/cli.py b/cli.py index 05dc6d012905..b3e1c2fb8cb1 100644 --- a/cli.py +++ b/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": diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 76642b7b29bb..d5973e51eab0 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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",)), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5ef6ae381352..23c1b7c66e31 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/tests/agent/test_battery.py b/tests/agent/test_battery.py new file mode 100644 index 000000000000..63474817f173 --- /dev/null +++ b/tests/agent/test_battery.py @@ -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)) == "" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index da815f9979c1..b3c20934e8cf 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d841cdc39f8a..e13f34910477 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 7d5f93a51d0f..b3c639846a65 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -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 diff --git a/ui-tui/src/__tests__/useBatteryPoll.test.ts b/ui-tui/src/__tests__/useBatteryPoll.test.ts new file mode 100644 index 000000000000..13cafff8153d --- /dev/null +++ b/ui-tui/src/__tests__/useBatteryPoll.test.ts @@ -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() + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index d0d759aa3060..33f2faec24b5 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -37,6 +37,17 @@ export interface StateSetter { 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 busy: boolean busyInputMode: BusyInputMode diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 8bd6f553d813..3a952c399d5e 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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('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('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', diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index b9d62fbe14ce..00102f577213 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -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', diff --git a/ui-tui/src/app/useBatteryPoll.ts b/ui-tui/src/app/useBatteryPoll.ts new file mode 100644 index 000000000000..50523aee94b3 --- /dev/null +++ b/ui-tui/src/app/useBatteryPoll.ts @@ -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 = 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(await gw.request('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]) +} diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index f845b7f2065d..4e08475afab3 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -217,6 +217,7 @@ export const applyDisplay = ( } patchUiState({ + battery: !!d.battery, busyInputMode: normalizeBusyInputMode(d.busy_input_mode), compact: !!d.tui_compact, detailsMode: resolveDetailsMode(d), diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index de67e7d13850..80a863c45bf5 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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) { diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 14d43dd367dd..cd2d4a9ba9de 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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). */} {'โ”€ '} + {showBattery ? ( + + {batteryText} + {' โ”‚ '} + + ) : null} {busy ? ( ) : showNotice ? null : ( @@ -770,6 +812,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) } interface StatusRuleProps { + battery?: BatteryInfo | null bgCount: number lastTurnEndedAt?: null | number liveSessionCount: number diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ebf7a672d02d..d641e39d0d4a 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -473,6 +473,7 @@ const StatusRulePane = memo(function StatusRulePane({ return (