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:
brooklyn! 2026-07-21 15:33:21 -05:00 committed by GitHub
commit 413ed6b9df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 747 additions and 4 deletions

131
agent/battery.py Normal file
View 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
View file

@ -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":

View file

@ -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",)),

View file

@ -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
View 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)) == ""

View file

@ -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,

View file

@ -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:

View file

@ -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

View 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()
})
})

View file

@ -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

View file

@ -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',

View file

@ -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',

View 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])
}

View file

@ -217,6 +217,7 @@ export const applyDisplay = (
}
patchUiState({
battery: !!d.battery,
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d),

View file

@ -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) {

View file

@ -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

View file

@ -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}

View file

@ -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 {

View file

@ -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.