"""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", "milestone"}, ...]``.
When any task carries a ``milestone`` label, the panel groups steps
under ``Milestone <letter>`` headers; otherwise it renders flat
(status-rank ordering)."""
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_within(self, tasks: list[dict]) -> list[dict]:
"""Tasks grouped by status for display (in_progress → pending →
failed/skipped → done), original order preserved within each rank.
Does not mutate the input."""
return sorted(
tasks,
key=lambda t: self._STATUS_RANK.get(t.get("status", "") or "pending", 1),
)
def _ordered_tasks(self) -> list[dict]:
"""All tasks, status-grouped for display (legacy flat path)."""
return self._ordered_within(self._tasks)
def _has_milestones(self) -> bool:
return any((t.get("milestone") or "") for t in self._tasks)
def _milestone_groups(self) -> list[tuple[str, list[dict]]]:
"""Group tasks by ``milestone`` in first-appearance order. The unnamed
group (``""``) is moved to the end so named milestones lead the panel."""
order: list[str] = []
by_group: dict[str, list[dict]] = {}
for t in self._tasks:
ms = t.get("milestone") or ""
if ms not in by_group:
order.append(ms)
by_group[ms] = []
by_group[ms].append(t)
if "" in by_group and order[-1] != "":
order.remove("")
order.append("")
return [(g, by_group[g]) for g in order]
def _render_task_line(self, t: dict, theme, dim: str) -> str:
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 ""
return f" [{icon_color}]{icon}[/] [{text_style}]{t.get('index', '')}. {text}[/]{suffix}{note}"
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"
]
if not self._has_milestones():
# Flat path (legacy): status-rank ordering, no group headers.
for t in self._ordered_tasks():
lines.append(self._render_task_line(t, theme, dim))
return "\n".join(lines)
# Grouped path: milestone headers (✓ when the whole group is done/
# skipped), status-rank ordering within each group. Plan order defines
# group sequence; the unnamed group goes last.
for ms, group_tasks in self._milestone_groups():
if ms:
group_done = all(
t.get("status") in ("done", "skipped") for t in group_tasks
)
check = f" [{theme.success.hex}]✓[/]" if group_done else ""
lines.append(f"[{dim}]Milestone {ms}{check}[/]")
for t in self._ordered_within(group_tasks):
lines.append(self._render_task_line(t, theme, dim))
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()