tenseleyflow/loader / c252d07

Browse files

Add persistent TodoListWidget with checkboxes and strikethrough above input area

Authored by espadonne
SHA
c252d0755b87117b4bb0f9016b0a4e91e062ecea
Parents
e765d37
Tree
3c3270e

5 changed files

StatusFile+-
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):
7272
     step_info: str
7373
 
7474
 
75
+@dataclass
76
+class TodoListUpdated(Message):
77
+    """Agent updated its todo list."""
78
+
79
+    todos: list
80
+
81
+
7582
 @dataclass
7683
 class RecoveryAttempted(Message):
7784
     """Error recovery was attempted."""
@@ -242,6 +249,29 @@ class EventAdapter:
242249
         except Exception:
243250
             pass
244251
 
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
+
245275
     def handle_event(self, event: AgentEvent) -> None:
246276
         """Convert AgentEvent to appropriate Textual message and post it."""
247277
         self._debug_log(f"handle_event: type={event.type}")
@@ -367,6 +397,12 @@ class EventAdapter:
367397
                     )
368398
                 )
369399
 
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
+
370406
             case "recovery":
371407
                 self.app.post_message(
372408
                     RecoveryAttempted(
src/loader/ui/app.pymodified
@@ -34,6 +34,7 @@ from .adapter import (
3434
     StreamChunk,
3535
     SubtaskStarted,
3636
     ThinkingStarted,
37
+    TodoListUpdated,
3738
     ToolCallCompleted,
3839
     ToolCallStarted,
3940
     TurnPhaseChanged,
@@ -47,6 +48,7 @@ from .widgets import (
4748
     QuestionModal,
4849
     StatusLine,
4950
     StreamingText,
51
+    TodoListWidget,
5052
     ToolCallWidget,
5153
 )
5254
 
@@ -109,6 +111,7 @@ class LoaderApp(App):
109111
         yield Container(
110112
             ScrollableContainer(id="message-area"),
111113
             ApprovalBar(id="approval-bar"),
114
+            TodoListWidget(id="todo-list"),
112115
             InputArea(id="input-area"),
113116
             StatusLine(id="status-line"),
114117
             id="main-container",
@@ -695,6 +698,10 @@ class LoaderApp(App):
695698
 
696699
         msg_area.scroll_end(animate=False)
697700
 
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
+
698705
     def on_plan_created(self, message: PlanCreated) -> None:
699706
         """Handle plan creation."""
700707
         msg_area = self.query_one("#message-area", ScrollableContainer)
src/loader/ui/styles/theme.tcssmodified
@@ -4,11 +4,11 @@ Screen {
44
     background: $surface;
55
 }
66
 
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 */
88
 #main-container {
99
     layout: grid;
1010
     grid-size: 1;
11
-    grid-rows: 1fr auto 3 1;
11
+    grid-rows: 1fr auto auto 3 1;
1212
 }
1313
 
1414
 #message-area {
src/loader/ui/widgets/__init__.pymodified
@@ -8,12 +8,14 @@ from .model_select import ModelSelectModal
88
 from .question import QuestionModal
99
 from .status_line import StatusLine
1010
 from .streaming import StreamingText
11
+from .todo_list import TodoListWidget
1112
 from .tool_widget import ToolCallWidget
1213
 
1314
 __all__ = [
1415
     "ApprovalBar",
1516
     "InputArea",
1617
     "StatusLine",
18
+    "TodoListWidget",
1719
     "ToolCallWidget",
1820
     "DiffWidget",
1921
     "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)