"""Bottom status line for the TUI.
Hosts the activity indicator (left), the context-window fill gauge, and a dim
key-combo summary (right). The indicator is the live "agent is working" signal;
the context gauge shows how full the LLM context window is (absolute tokens +
percent); the combos keep the line useful while idle. Sits at the very bottom,
replacing Textual's Footer so these share one line.
"""
from __future__ import annotations
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
from clients.terminal.tui.themes import get_active_theme
from clients.terminal.tui.widgets.activity_indicator import ActivityIndicator
from clients.terminal.tui.widgets.elapsed_timer import ElapsedTimer
_COMBOS = "Ctrl+P palette · Ctrl+X Q quit · Esc stop"
def _humanize(n: int) -> str:
"""Compact token count: 12345 -> '12.3k', 32768 -> '32.8k', 500 -> '500'."""
if n < 1000:
return str(n)
if n < 1_000_000:
return f"{n / 1000:.1f}k"
return f"{n / 1_000_000:.2f}M"
class ContextFill(Static):
"""Context-window fill: ``ctx used/max · pct%``, colored by fill level."""
def __init__(self) -> None:
super().__init__("—")
self._used: int | None = None
self._max: int | None = None
def set_tokens(self, used: int | None, total: int | None) -> None:
# None means the backend didn't report; keep the last known value.
if used is not None:
self._used = used
if total is not None:
self._max = total
if not self.is_mounted:
return
if self._used is None or not self._max:
self.update(Text("—", style=get_active_theme().text_dim.hex))
return
pct = round(self._used / self._max * 100)
color = self._color_for(pct)
self.update(
Text(f"{_humanize(self._used)}/{_humanize(self._max)} · {pct}%", style=color)
)
def _color_for(self, pct: int) -> str:
theme = get_active_theme()
if pct >= 90:
return theme.error.hex
if pct >= 70:
return theme.warning.hex
return theme.text_dim.hex
class StatusBar(Horizontal):
"""One-line bottom bar: activity indicator + context fill + key combos."""
DEFAULT_CSS = """
StatusBar {
height: 1;
background: $tui-panel;
color: $tui-text-dim;
padding: 0 1;
}
StatusBar ActivityIndicator {
width: auto;
height: 1;
}
StatusBar ElapsedTimer {
width: auto;
height: 1;
margin-left: 1;
}
StatusBar ContextFill {
width: auto;
height: 1;
margin-left: 1;
}
StatusBar .combos {
color: $tui-text-dim;
text-align: right;
width: 1fr;
height: 1;
}
"""
def __init__(self) -> None:
super().__init__()
self._activity = ActivityIndicator()
self._elapsed = ElapsedTimer()
# NOTE: do not name this attribute ``_context`` — that shadows
# ``MessagePump._context``, the internal context manager Textual wraps
# around message processing. Shadowing it (e.g. with a widget) hangs the
# app's message pump at mount time.
self._ctx_fill = ContextFill()
self._combos = Static(_COMBOS, classes="combos")
def compose(self) -> ComposeResult:
yield self._activity
yield self._elapsed
yield self._ctx_fill
yield self._combos
def activity_start(self, label: str) -> None:
self._activity.start(label)
def activity_label(self, label: str) -> None:
self._activity.set_label(label)
def activity_stop(self) -> None:
self._activity.stop()
def set_context(self, used: int | None, total: int | None) -> None:
self._ctx_fill.set_tokens(used, total)
def elapsed_start(self) -> None:
self._elapsed.start()
def elapsed_stop(self, final_seconds: float | None = None) -> None:
self._elapsed.stop(final_seconds)