Newer
Older
navi-1 / clients / terminal / tui / widgets / status_bar.py
"""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)