Newer
Older
navi-1 / clients / terminal / tui / widgets / todo_list.py
"""Todo list side-panel widget for the TUI.

Renders the agent's current todo plan (the parent session's todo row) with
status-coloured markers and a progress header. Fed by:

  * ``todo_updated`` WS events (live, after each tool turn / planning auto-populate)
  * ``api.get_todos`` (initial seed on attach/switch, before the first WS event)

Sub-agent todos are NOT shown here yet — they live in isolated KV rows and will
be surfaced as nested sub-lists in Phase 3.

Layout: ``TodoPanel`` (a scrollable container, ``height: 1fr``) wraps a
``TodoList`` (a plain ``Static``, ``height: auto``). The info block above
shrinks to its content; the todo block fills the rest of the right column and
scrolls when the plan is longer than the viewport.
"""

from __future__ import annotations

from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.widgets import Static

from clients.terminal.tui.themes import get_active_theme

_STATUS_ICON: dict[str, str] = {
    "pending":     "○",
    "in_progress": "◎",
    "done":        "✓",
    "failed":      "✗",
    "skipped":     "—",
}

# Theme attribute name (on the active theme) used to colour each status marker.
_STATUS_COLOR_ATTR: dict[str, str] = {
    "pending":     "text_dim",
    "in_progress": "accent",
    "done":        "success",
    "failed":      "error",
    "skipped":     "text_dim",
}


class TodoList(Static):
    """Renders the live todo plan as coloured, status-marked lines.

    A plain Static whose content is rebuilt from the latest task payload via
    ``set_tasks`` / ``clear`` / ``refresh_content`` (no compose override, and
    never a method named ``_render`` — that shadows Widget._render and crashes
    Textual's layout pass).
    """

    DEFAULT_CSS = """
    TodoList {
        height: auto;
        color: $tui-text-muted;
        padding: 0;
    }
    """

    def __init__(self) -> None:
        super().__init__("[dim]No plan yet[/dim]")
        self._tasks: list[dict] = []

    def set_tasks(self, tasks: list[dict]) -> None:
        """Replace the rendered todo. ``tasks`` is the REST/WS payload:
        ``[{"index", "text", "status", "validation"}, ...]``."""
        self._tasks = list(tasks)
        self.update(self._build_content())

    def clear(self) -> None:
        self._tasks = []
        self.update(self._build_content())

    def refresh_content(self) -> None:
        """Re-render with the current tasks (used after a theme switch so the
        status-marker colours pick up the new palette)."""
        self.update(self._build_content())

    # Display order: in_progress → pending → failed/skipped → done. Lower rank
    # = higher on screen. Unknown statuses fall in with pending so new work
    # stays visible rather than sinking below completed steps.
    _STATUS_RANK: dict[str, int] = {
        "in_progress": 0,
        "pending":     1,
        "failed":      2,
        "skipped":      2,
        "done":        3,
    }

    def _ordered_tasks(self) -> list[dict]:
        """Tasks grouped by status for display, original order preserved within
        each group. Does not mutate ``self._tasks``."""
        return sorted(
            self._tasks,
            key=lambda t: self._STATUS_RANK.get(t.get("status", "") or "pending", 1),
        )

    def _build_content(self) -> str:
        theme = get_active_theme()
        dim = theme.text_dim.hex
        if not self._tasks:
            return f"[{dim}]No plan yet[/]"
        n = len(self._tasks)
        done = sum(1 for t in self._tasks if t.get("status") == "done")
        # Progress header: count in the done colour, total dim.
        lines: list[str] = [
            f"[{theme.success.hex}]{done}[/{theme.success.hex}]/{n} done"
        ]
        # Display order: active work on top, queued next, blocked/dismissed
        # (failed + skipped) below the queue, completed at the bottom. Stable
        # within a group — original payload order is preserved, only the groups
        # are reordered. ``self._tasks`` itself keeps the plan's original order.
        for t in self._ordered_tasks():
            status = t.get("status", "") or "pending"
            icon = _STATUS_ICON.get(status, "?")
            icon_color = getattr(theme, _STATUS_COLOR_ATTR.get(status, "text_dim"), theme.text_dim).hex
            text = t.get("text", "")
            # Line emphasis: the active step is bold + bright; completed/skipped
            # are dimmed; failed pops in the warning colour; pending is muted.
            if status == "in_progress":
                text_style = f"bold {theme.text.hex}"
            elif status in ("done", "skipped"):
                text_style = f"dim {theme.text_muted.hex}"
            elif status == "failed":
                text_style = theme.warning.hex
            else:
                text_style = theme.text_muted.hex
            suffix = f" [{dim}]({status})[/]" if status not in ("pending", "done") else ""
            note = f" [{dim}]✓ {t['validation']}[/]" if t.get("validation") else ""
            lines.append(
                f"  [{icon_color}]{icon}[/] [{text_style}]{t.get('index', '')}. {text}[/]{suffix}{note}"
            )
        return "\n".join(lines)


class TodoPanel(ScrollableContainer):
    """Right-column scrollable frame around the todo plan.

    ``height: 1fr`` so it fills whatever the info block above leaves free; the
    inner ``TodoList`` is ``height: auto`` and this container scrolls when the
    plan is longer than the viewport.
    """

    DEFAULT_CSS = """
    TodoPanel {
        border: solid $tui-border;
        background: $tui-panel;
        color: $tui-text-muted;
        padding: 0 1;
        height: 1fr;
        width: 1fr;
    }
    """

    def __init__(self) -> None:
        super().__init__()
        self._todo = TodoList()

    def compose(self) -> ComposeResult:
        yield self._todo

    def on_mount(self) -> None:
        self._todo.styles.height = "auto"
        self.border_title = "TODO"

    def set_tasks(self, tasks: list[dict]) -> None:
        self._todo.set_tasks(tasks)

    def clear(self) -> None:
        self._todo.clear()

    def refresh_content(self) -> None:
        self._todo.refresh_content()