From c252d0755b87117b4bb0f9016b0a4e91e062ecea Mon Sep 17 00:00:00 2001 From: espadonne Date: Sun, 12 Apr 2026 13:47:05 -0400 Subject: [PATCH 1/5] Add persistent TodoListWidget with checkboxes and strikethrough above input area --- src/loader/ui/adapter.py | 36 +++++++++++++++ src/loader/ui/app.py | 7 +++ src/loader/ui/styles/theme.tcss | 4 +- src/loader/ui/widgets/__init__.py | 2 + src/loader/ui/widgets/todo_list.py | 72 ++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/loader/ui/widgets/todo_list.py diff --git a/src/loader/ui/adapter.py b/src/loader/ui/adapter.py index e60dc00..acc16bf 100644 --- a/src/loader/ui/adapter.py +++ b/src/loader/ui/adapter.py @@ -72,6 +72,13 @@ class StepStarted(Message): step_info: str +@dataclass +class TodoListUpdated(Message): + """Agent updated its todo list.""" + + todos: list + + @dataclass class RecoveryAttempted(Message): """Error recovery was attempted.""" @@ -242,6 +249,29 @@ def _debug_log(self, message: str) -> None: except Exception: pass + @staticmethod + def _extract_todos(content: str, tool_args: dict) -> list[dict]: + """Extract todo items from TodoWrite result or args.""" + import json + + # Try parsing the content as JSON (may be wrapped in Observation prefix) + for candidate in [content, content.split("Result: ", 1)[-1] if "Result:" in content else ""]: + candidate = candidate.strip() + if not candidate: + continue + try: + data = json.loads(candidate) + if isinstance(data, dict): + todos = data.get("new_todos", []) + if isinstance(todos, list): + return todos + except (json.JSONDecodeError, TypeError): + continue + + # Fall back to the original tool args + todos = tool_args.get("todos", []) + return todos if isinstance(todos, list) else [] + def handle_event(self, event: AgentEvent) -> None: """Convert AgentEvent to appropriate Textual message and post it.""" self._debug_log(f"handle_event: type={event.type}") @@ -367,6 +397,12 @@ def handle_event(self, event: AgentEvent) -> None: ) ) + # Update the todo list widget when TodoWrite succeeds + if tool_name == "TodoWrite" and not event.is_error: + new_todos = self._extract_todos(event.content, tool_args) + if new_todos: + self.app.post_message(TodoListUpdated(todos=new_todos)) + case "recovery": self.app.post_message( RecoveryAttempted( diff --git a/src/loader/ui/app.py b/src/loader/ui/app.py index d567650..3cb2916 100644 --- a/src/loader/ui/app.py +++ b/src/loader/ui/app.py @@ -34,6 +34,7 @@ StreamChunk, SubtaskStarted, ThinkingStarted, + TodoListUpdated, ToolCallCompleted, ToolCallStarted, TurnPhaseChanged, @@ -47,6 +48,7 @@ QuestionModal, StatusLine, StreamingText, + TodoListWidget, ToolCallWidget, ) @@ -109,6 +111,7 @@ def compose(self) -> ComposeResult: yield Container( ScrollableContainer(id="message-area"), ApprovalBar(id="approval-bar"), + TodoListWidget(id="todo-list"), InputArea(id="input-area"), StatusLine(id="status-line"), id="main-container", @@ -695,6 +698,10 @@ def on_tool_call_completed(self, message: ToolCallCompleted) -> None: msg_area.scroll_end(animate=False) + def on_todo_list_updated(self, message: TodoListUpdated) -> None: + """Update the persistent todo widget when TodoWrite fires.""" + self.query_one("#todo-list", TodoListWidget).update_todos(message.todos) + def on_plan_created(self, message: PlanCreated) -> None: """Handle plan creation.""" msg_area = self.query_one("#message-area", ScrollableContainer) diff --git a/src/loader/ui/styles/theme.tcss b/src/loader/ui/styles/theme.tcss index 1bae0b1..35f3bc9 100644 --- a/src/loader/ui/styles/theme.tcss +++ b/src/loader/ui/styles/theme.tcss @@ -4,11 +4,11 @@ Screen { background: $surface; } -/* Main layout — 4 rows: message area, approval bar (auto-hidden), input, status */ +/* Main layout — 5 rows: messages, approval bar, todo list, input, status */ #main-container { layout: grid; grid-size: 1; - grid-rows: 1fr auto 3 1; + grid-rows: 1fr auto auto 3 1; } #message-area { diff --git a/src/loader/ui/widgets/__init__.py b/src/loader/ui/widgets/__init__.py index 5f70de7..ba20e19 100644 --- a/src/loader/ui/widgets/__init__.py +++ b/src/loader/ui/widgets/__init__.py @@ -8,12 +8,14 @@ from .question import QuestionModal from .status_line import StatusLine from .streaming import StreamingText +from .todo_list import TodoListWidget from .tool_widget import ToolCallWidget __all__ = [ "ApprovalBar", "InputArea", "StatusLine", + "TodoListWidget", "ToolCallWidget", "DiffWidget", "StreamingText", diff --git a/src/loader/ui/widgets/todo_list.py b/src/loader/ui/widgets/todo_list.py new file mode 100644 index 0000000..c8a4660 --- /dev/null +++ b/src/loader/ui/widgets/todo_list.py @@ -0,0 +1,72 @@ +"""Persistent todo list widget rendered above the input area.""" + +from __future__ import annotations + +from rich.text import Text +from textual.app import ComposeResult +from textual.widget import Widget +from textual.widgets import Static + +_STATUS_ICONS = { + "pending": (" [ ] ", "dim"), + "in_progress": (" [~] ", "bold yellow"), + "completed": (" [x] ", "green"), +} + + +class TodoListWidget(Widget): + """Renders the agent's current todo list with checkboxes and strikethrough.""" + + DEFAULT_CSS = """ + TodoListWidget { + height: auto; + max-height: 10; + display: none; + padding: 0 1; + border-top: solid $primary-darken-2; + } + + TodoListWidget.has-items { + display: block; + } + + TodoListWidget #todo-content { + width: 100%; + } + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._items: list[dict[str, str]] = [] + + def compose(self) -> ComposeResult: + yield Static("", id="todo-content") + + def update_todos(self, todos: list[dict[str, str]]) -> None: + """Replace the displayed todo list.""" + self._items = list(todos) + if not self._items: + self.remove_class("has-items") + return + self.add_class("has-items") + self._render() + + def _render(self) -> None: + content = Text() + content.append(" Tasks\n", style="bold") + for item in self._items: + status = item.get("status", "pending") + icon, icon_style = _STATUS_ICONS.get(status, _STATUS_ICONS["pending"]) + label = item.get("content", "") + + content.append(icon, style=icon_style) + if status == "completed": + content.append(label, style="strike dim") + elif status == "in_progress": + active = item.get("active_form", label) + content.append(active, style="bold") + else: + content.append(label) + content.append("\n") + + self.query_one("#todo-content", Static).update(content) From 4545623ce4b66506b2da14a6c96e308d5802b1c8 Mon Sep 17 00:00:00 2001 From: espadonne Date: Sun, 12 Apr 2026 14:00:56 -0400 Subject: [PATCH 2/5] Raise max_iterations from 15 to 200; text loop detector is the real termination mechanism --- src/loader/agent/loop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/loader/agent/loop.py b/src/loader/agent/loop.py index c9e03a7..0251188 100644 --- a/src/loader/agent/loop.py +++ b/src/loader/agent/loop.py @@ -75,7 +75,7 @@ class ReasoningConfig: @dataclass class AgentConfig: """Configuration for the agent.""" - max_iterations: int = 15 # Reduced from 20 + max_iterations: int = 200 # High cap; text loop detector is the real termination mechanism temperature: float = 0.3 # Low for better instruction following max_tokens: int = 2048 # Reduced from 4096, most responses are shorter force_react: bool = False # Force ReAct even if model supports native tools From 50fe2540706099152ddcb21a2c444a5940d8111c Mon Sep 17 00:00:00 2001 From: espadonne Date: Sun, 12 Apr 2026 14:01:18 -0400 Subject: [PATCH 3/5] Emit visible error when iteration cap is hit instead of silent exit --- src/loader/runtime/turn_loop.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/loader/runtime/turn_loop.py b/src/loader/runtime/turn_loop.py index 94d1015..4a0705c 100644 --- a/src/loader/runtime/turn_loop.py +++ b/src/loader/runtime/turn_loop.py @@ -142,6 +142,23 @@ async def run_loop( rlog.loop_exit(state.iterations, exit.reason_code, exit.reason_summary) return exit break + else: + # Loop exhausted max_iterations without breaking — notify user + await emit( + AgentEvent( + type="error", + content=( + f"Reached iteration limit ({self.context.config.max_iterations}). " + "Stopping — the work above may be incomplete." + ), + ) + ) + exit = TurnLoopExit( + reason_code="max_iterations_reached", + reason_summary=f"Stopped after {state.iterations} iterations (limit reached)", + ) + rlog.loop_exit(state.iterations, exit.reason_code, exit.reason_summary) + return exit exit = TurnLoopExit( reason_code="turn_complete", From 1c7aa50ba5797d6abb190bc4154146f957464b87 Mon Sep 17 00:00:00 2001 From: espadonne Date: Sun, 12 Apr 2026 14:01:38 -0400 Subject: [PATCH 4/5] Strengthen TodoWrite guidance: call it first for 3+ step tasks --- src/loader/runtime/prompting.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/loader/runtime/prompting.py b/src/loader/runtime/prompting.py index f218ab8..e2fd1d2 100644 --- a/src/loader/runtime/prompting.py +++ b/src/loader/runtime/prompting.py @@ -114,7 +114,8 @@ ## Execute Mode - Use tools directly to perform the task - Read relevant files before editing them -- Keep `TodoWrite` current for multi-step work when progress tracking matters +- For tasks with 3+ steps, call `TodoWrite` first to outline the steps, then + update it as each step completes. This tracks progress visibly for the user - Concise reporting is fine, and numbered lists are allowed when they communicate plan or evidence clearly """, From 96d92d0f89850cdcf747fedc1996e3d8f7b4d3c0 Mon Sep 17 00:00:00 2001 From: espadonne Date: Sun, 12 Apr 2026 14:11:25 -0400 Subject: [PATCH 5/5] Match tool widgets by name instead of FIFO to prevent result/widget swaps --- src/loader/ui/app.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/loader/ui/app.py b/src/loader/ui/app.py index 3cb2916..90c6ff5 100644 --- a/src/loader/ui/app.py +++ b/src/loader/ui/app.py @@ -657,7 +657,19 @@ def on_tool_call_completed(self, message: ToolCallCompleted) -> None: pass # Get the corresponding tool widget from queue (FIFO) - tool_widget = self._tool_widget_queue.pop(0) if self._tool_widget_queue else None + # Match widget by tool name instead of blind FIFO to prevent + # result/widget mismatches when events arrive out of order + tool_widget = None + if self._tool_widget_queue: + for i, w in enumerate(self._tool_widget_queue): + # Match on tool name (strip "verify " prefix for verification phase) + widget_name = w.tool_name.removeprefix("verify ") + if widget_name == message.tool_name: + tool_widget = self._tool_widget_queue.pop(i) + break + else: + # No name match — fall back to FIFO + tool_widget = self._tool_widget_queue.pop(0) # Check if this is an edit tool with diff info # Note: old_string can be empty string (inserting), so check `is not None`