"""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()