"""Renderers for the ``todo`` tool — the plan tracker.
The default ``ToolStartedRenderer`` dumps the call args as raw JSON, which is
unreadable for a tool as central as todo (and for ``set`` produces a wall of
quoted strings). These renderers turn the call into a compact, op-aware card
and collapse the result into a single status line — the full live plan already
lives in the side panel (``TodoList``), so the chat card only needs to convey
*what changed*, not the whole state.
Pattern mirrors ``subagent.py``: registered before the generic tool renderers.
"""
from __future__ import annotations
import json
from rich.box import ROUNDED
from rich.console import RenderableType
from rich.json import JSON as RichJSON
from rich.panel import Panel
from rich.text import Text
from clients.terminal.tui.themes import get_active_theme
from .base import ContentRenderer
_TODO_TOOL = "todo"
# Same markers as the side panel (todo_list.py) so the chat card and the panel
# read as one vocabulary.
_ICON = {"pending": "○", "in_progress": "◎", "done": "✓", "failed": "✗", "skipped": "—"}
# Validation status text colour by the new status (used in update cards).
_VAL_COLOR = {
"done": "success",
"failed": "error",
"in_progress": "accent",
"skipped": "text_dim",
"pending": "text_dim",
}
def _truncate(text: str, limit: int) -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return text[:limit].rstrip() + " …"
class TodoStartedRenderer(ContentRenderer):
"""Render a ``todo`` call start as a compact, op-aware card."""
def accepts(self, msg: dict) -> bool:
return msg.get("type") == "tool_started" and msg.get("tool") == _TODO_TOOL
def render(self, msg: dict) -> RenderableType:
theme = get_active_theme()
args = msg.get("args") or {}
op = args.get("op")
accent = theme.accent.hex
body = self._body(msg, theme)
title = self._title(op, args, accent)
panel = Panel(
body,
title=title,
title_align="left",
border_style=theme.tool_border.hex,
box=ROUNDED,
)
if bool(msg.get("is_subagent", False)):
from rich.padding import Padding
return Padding(panel, (0, 0, 0, 2))
return panel
def _title(self, op: str | None, args: dict, accent: str) -> str:
if op == "set":
n = len(args.get("tasks") or [])
return f"→ todo · set plan ({n})"
if op == "update":
idx = args.get("index")
status = args.get("status", "")
arrow = f" → {status}" if status else ""
return f"→ todo · #{idx}{arrow}"
if op == "view":
return "→ todo · view"
if op == "clear":
return "→ todo · clear"
return f"→ todo · {op or '?'}"
def _body(self, msg: dict, theme) -> RenderableType:
args = msg.get("args") or {}
op = args.get("op")
dim = theme.text_dim.hex
muted = theme.text_muted.hex
if op == "set":
tasks = args.get("tasks") or []
if not tasks:
return Text("(empty plan)", style=dim)
lines = Text()
for i, t in enumerate(tasks, 1):
if i > 1:
lines.append("\n")
lines.append(f" {_ICON['pending']} ", style=dim)
lines.append(f"{i}. {_truncate(str(t), 80)}", style=muted)
return lines
if op == "update":
status = args.get("status", "")
validation = (args.get("validation") or "").strip()
# The step text comes from ToolStarted.metadata (enriched on the
# backend by loading the plan before the tool runs — the LLM's
# update args carry only the index). Absent on history replay.
step_text = ""
meta = msg.get("metadata")
if isinstance(meta, dict):
step_text = (meta.get("step_text") or "").strip()
body = Text()
has_content = False
if step_text:
body.append(_truncate(step_text, 100), style=theme.text.hex)
has_content = True
if validation:
color_attr = _VAL_COLOR.get(status, "text_dim")
color = getattr(theme, color_attr, theme.text_dim).hex
if has_content:
body.append("\n")
body.append("verified: ", style=dim)
body.append(_truncate(validation, 160), style=color)
has_content = True
if not has_content:
# No step text (history replay) and no validation — keep the
# placeholder so the card isn't empty.
body.append("no validation", style=dim)
return body
if op in ("view", "clear"):
return Text("")
# Unknown op — fall back to readable JSON so the call is still legible.
try:
return RichJSON(json.dumps(args))
except Exception:
return Text(str(args), style=dim)
class TodoResultRenderer(ContentRenderer):
"""Render a ``todo`` result as a single compact status line.
The full plan is in the side panel; the chat card only needs the summary.
The result text from ``todo._render`` starts with ``Plan — X/N done:``;
we parse that. Other result strings (``Plan is empty.``, ``Plan cleared.``,
``No plan set for this session.``) map to short labels.
"""
def accepts(self, msg: dict) -> bool:
return msg.get("type") == "tool_call" and msg.get("tool") == _TODO_TOOL
def render(self, msg: dict) -> RenderableType:
theme = get_active_theme()
success = msg.get("success", True)
result = msg.get("result")
text = str(result) if result is not None else ""
if bool(msg.get("is_subagent", False)):
from rich.padding import Padding
return Padding(self._card(success, text, theme), (0, 0, 0, 2))
return self._card(success, text, theme)
def _card(self, success: bool, text: str, theme) -> Panel:
if not success:
# e.g. validation_required — surface the first line compactly.
first = text.splitlines()[0].strip() if text else "error"
color = theme.tool_error
return Panel(
Text(_truncate(first, 120), style=color.hex),
title="← todo ✗",
title_align="left",
border_style=color.hex,
box=ROUNDED,
)
# The step text lives in the *call* card (started, via metadata); the
# result card only carries the new overall state — one compact line.
label = self._status_label(text)
return Panel(
self._status_line(label, theme),
title="← todo ✓",
title_align="left",
border_style=theme.tool_success.hex,
box=ROUNDED,
)
def _status_label(self, text: str) -> str:
first = text.splitlines()[0].strip() if text else ""
# "Plan — 2/5 done:"
if first.startswith("Plan —") and "done" in first:
tail = first[len("Plan —"):].rstrip(":").strip() # "2/5 done"
return f"plan · {tail}"
if first == "Plan is empty.":
return "plan · empty"
if first == "Plan cleared.":
return "plan · cleared"
if first == "No plan set for this session.":
return "plan · no plan"
return _truncate(first, 80) or "plan · ok"
def _status_line(self, label: str, theme) -> Text:
dim = theme.text_dim.hex
if label.startswith("plan · ") and "/" in label:
# "plan · 2/5 done" → prefix dim, "2" success, "/5 done" dim.
body = Text("plan · ", style=dim)
rest = label[len("plan · "):] # "2/5 done"
slash = rest.find("/")
done_n = rest[:slash]
after = rest[slash:] # "/5 done"
body.append(done_n, style=theme.success.hex)
body.append(after, style=dim)
return body
return Text(label, style=dim)