Add persistent TodoListWidget with checkboxes and strikethrough above input area
- SHA
c252d0755b87117b4bb0f9016b0a4e91e062ecea- Parents
-
e765d37 - Tree
3c3270e
c252d07
c252d0755b87117b4bb0f9016b0a4e91e062eceae765d37
3c3270e| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/ui/adapter.py
|
36 | 0 |
| M |
src/loader/ui/app.py
|
7 | 0 |
| M |
src/loader/ui/styles/theme.tcss
|
2 | 2 |
| M |
src/loader/ui/widgets/__init__.py
|
2 | 0 |
| A |
src/loader/ui/widgets/todo_list.py
|
72 | 0 |
src/loader/ui/adapter.pymodified@@ -72,6 +72,13 @@ class StepStarted(Message): | ||
| 72 | 72 | step_info: str |
| 73 | 73 | |
| 74 | 74 | |
| 75 | +@dataclass | |
| 76 | +class TodoListUpdated(Message): | |
| 77 | + """Agent updated its todo list.""" | |
| 78 | + | |
| 79 | + todos: list | |
| 80 | + | |
| 81 | + | |
| 75 | 82 | @dataclass |
| 76 | 83 | class RecoveryAttempted(Message): |
| 77 | 84 | """Error recovery was attempted.""" |
@@ -242,6 +249,29 @@ class EventAdapter: | ||
| 242 | 249 | except Exception: |
| 243 | 250 | pass |
| 244 | 251 | |
| 252 | + @staticmethod | |
| 253 | + def _extract_todos(content: str, tool_args: dict) -> list[dict]: | |
| 254 | + """Extract todo items from TodoWrite result or args.""" | |
| 255 | + import json | |
| 256 | + | |
| 257 | + # Try parsing the content as JSON (may be wrapped in Observation prefix) | |
| 258 | + for candidate in [content, content.split("Result: ", 1)[-1] if "Result:" in content else ""]: | |
| 259 | + candidate = candidate.strip() | |
| 260 | + if not candidate: | |
| 261 | + continue | |
| 262 | + try: | |
| 263 | + data = json.loads(candidate) | |
| 264 | + if isinstance(data, dict): | |
| 265 | + todos = data.get("new_todos", []) | |
| 266 | + if isinstance(todos, list): | |
| 267 | + return todos | |
| 268 | + except (json.JSONDecodeError, TypeError): | |
| 269 | + continue | |
| 270 | + | |
| 271 | + # Fall back to the original tool args | |
| 272 | + todos = tool_args.get("todos", []) | |
| 273 | + return todos if isinstance(todos, list) else [] | |
| 274 | + | |
| 245 | 275 | def handle_event(self, event: AgentEvent) -> None: |
| 246 | 276 | """Convert AgentEvent to appropriate Textual message and post it.""" |
| 247 | 277 | self._debug_log(f"handle_event: type={event.type}") |
@@ -367,6 +397,12 @@ class EventAdapter: | ||
| 367 | 397 | ) |
| 368 | 398 | ) |
| 369 | 399 | |
| 400 | + # Update the todo list widget when TodoWrite succeeds | |
| 401 | + if tool_name == "TodoWrite" and not event.is_error: | |
| 402 | + new_todos = self._extract_todos(event.content, tool_args) | |
| 403 | + if new_todos: | |
| 404 | + self.app.post_message(TodoListUpdated(todos=new_todos)) | |
| 405 | + | |
| 370 | 406 | case "recovery": |
| 371 | 407 | self.app.post_message( |
| 372 | 408 | RecoveryAttempted( |
src/loader/ui/app.pymodified@@ -34,6 +34,7 @@ from .adapter import ( | ||
| 34 | 34 | StreamChunk, |
| 35 | 35 | SubtaskStarted, |
| 36 | 36 | ThinkingStarted, |
| 37 | + TodoListUpdated, | |
| 37 | 38 | ToolCallCompleted, |
| 38 | 39 | ToolCallStarted, |
| 39 | 40 | TurnPhaseChanged, |
@@ -47,6 +48,7 @@ from .widgets import ( | ||
| 47 | 48 | QuestionModal, |
| 48 | 49 | StatusLine, |
| 49 | 50 | StreamingText, |
| 51 | + TodoListWidget, | |
| 50 | 52 | ToolCallWidget, |
| 51 | 53 | ) |
| 52 | 54 | |
@@ -109,6 +111,7 @@ class LoaderApp(App): | ||
| 109 | 111 | yield Container( |
| 110 | 112 | ScrollableContainer(id="message-area"), |
| 111 | 113 | ApprovalBar(id="approval-bar"), |
| 114 | + TodoListWidget(id="todo-list"), | |
| 112 | 115 | InputArea(id="input-area"), |
| 113 | 116 | StatusLine(id="status-line"), |
| 114 | 117 | id="main-container", |
@@ -695,6 +698,10 @@ class LoaderApp(App): | ||
| 695 | 698 | |
| 696 | 699 | msg_area.scroll_end(animate=False) |
| 697 | 700 | |
| 701 | + def on_todo_list_updated(self, message: TodoListUpdated) -> None: | |
| 702 | + """Update the persistent todo widget when TodoWrite fires.""" | |
| 703 | + self.query_one("#todo-list", TodoListWidget).update_todos(message.todos) | |
| 704 | + | |
| 698 | 705 | def on_plan_created(self, message: PlanCreated) -> None: |
| 699 | 706 | """Handle plan creation.""" |
| 700 | 707 | msg_area = self.query_one("#message-area", ScrollableContainer) |
src/loader/ui/styles/theme.tcssmodified@@ -4,11 +4,11 @@ Screen { | ||
| 4 | 4 | background: $surface; |
| 5 | 5 | } |
| 6 | 6 | |
| 7 | -/* Main layout — 4 rows: message area, approval bar (auto-hidden), input, status */ | |
| 7 | +/* Main layout — 5 rows: messages, approval bar, todo list, input, status */ | |
| 8 | 8 | #main-container { |
| 9 | 9 | layout: grid; |
| 10 | 10 | grid-size: 1; |
| 11 | - grid-rows: 1fr auto 3 1; | |
| 11 | + grid-rows: 1fr auto auto 3 1; | |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | 14 | #message-area { |
src/loader/ui/widgets/__init__.pymodified@@ -8,12 +8,14 @@ from .model_select import ModelSelectModal | ||
| 8 | 8 | from .question import QuestionModal |
| 9 | 9 | from .status_line import StatusLine |
| 10 | 10 | from .streaming import StreamingText |
| 11 | +from .todo_list import TodoListWidget | |
| 11 | 12 | from .tool_widget import ToolCallWidget |
| 12 | 13 | |
| 13 | 14 | __all__ = [ |
| 14 | 15 | "ApprovalBar", |
| 15 | 16 | "InputArea", |
| 16 | 17 | "StatusLine", |
| 18 | + "TodoListWidget", | |
| 17 | 19 | "ToolCallWidget", |
| 18 | 20 | "DiffWidget", |
| 19 | 21 | "StreamingText", |
src/loader/ui/widgets/todo_list.pyadded@@ -0,0 +1,72 @@ | ||
| 1 | +"""Persistent todo list widget rendered above the input area.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from rich.text import Text | |
| 6 | +from textual.app import ComposeResult | |
| 7 | +from textual.widget import Widget | |
| 8 | +from textual.widgets import Static | |
| 9 | + | |
| 10 | +_STATUS_ICONS = { | |
| 11 | + "pending": (" [ ] ", "dim"), | |
| 12 | + "in_progress": (" [~] ", "bold yellow"), | |
| 13 | + "completed": (" [x] ", "green"), | |
| 14 | +} | |
| 15 | + | |
| 16 | + | |
| 17 | +class TodoListWidget(Widget): | |
| 18 | + """Renders the agent's current todo list with checkboxes and strikethrough.""" | |
| 19 | + | |
| 20 | + DEFAULT_CSS = """ | |
| 21 | + TodoListWidget { | |
| 22 | + height: auto; | |
| 23 | + max-height: 10; | |
| 24 | + display: none; | |
| 25 | + padding: 0 1; | |
| 26 | + border-top: solid $primary-darken-2; | |
| 27 | + } | |
| 28 | + | |
| 29 | + TodoListWidget.has-items { | |
| 30 | + display: block; | |
| 31 | + } | |
| 32 | + | |
| 33 | + TodoListWidget #todo-content { | |
| 34 | + width: 100%; | |
| 35 | + } | |
| 36 | + """ | |
| 37 | + | |
| 38 | + def __init__(self, **kwargs) -> None: | |
| 39 | + super().__init__(**kwargs) | |
| 40 | + self._items: list[dict[str, str]] = [] | |
| 41 | + | |
| 42 | + def compose(self) -> ComposeResult: | |
| 43 | + yield Static("", id="todo-content") | |
| 44 | + | |
| 45 | + def update_todos(self, todos: list[dict[str, str]]) -> None: | |
| 46 | + """Replace the displayed todo list.""" | |
| 47 | + self._items = list(todos) | |
| 48 | + if not self._items: | |
| 49 | + self.remove_class("has-items") | |
| 50 | + return | |
| 51 | + self.add_class("has-items") | |
| 52 | + self._render() | |
| 53 | + | |
| 54 | + def _render(self) -> None: | |
| 55 | + content = Text() | |
| 56 | + content.append(" Tasks\n", style="bold") | |
| 57 | + for item in self._items: | |
| 58 | + status = item.get("status", "pending") | |
| 59 | + icon, icon_style = _STATUS_ICONS.get(status, _STATUS_ICONS["pending"]) | |
| 60 | + label = item.get("content", "") | |
| 61 | + | |
| 62 | + content.append(icon, style=icon_style) | |
| 63 | + if status == "completed": | |
| 64 | + content.append(label, style="strike dim") | |
| 65 | + elif status == "in_progress": | |
| 66 | + active = item.get("active_form", label) | |
| 67 | + content.append(active, style="bold") | |
| 68 | + else: | |
| 69 | + content.append(label) | |
| 70 | + content.append("\n") | |
| 71 | + | |
| 72 | + self.query_one("#todo-content", Static).update(content) | |