diff --git a/clients/terminal/tui/widgets/todo_list.py b/clients/terminal/tui/widgets/todo_list.py index a6cd153..0de0ba6 100644 --- a/clients/terminal/tui/widgets/todo_list.py +++ b/clients/terminal/tui/widgets/todo_list.py @@ -77,6 +77,25 @@ 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_tasks(self) -> list[dict]: + """Tasks grouped by status for display, original order preserved within + each group. Does not mutate ``self._tasks``.""" + return sorted( + self._tasks, + key=lambda t: self._STATUS_RANK.get(t.get("status", "") or "pending", 1), + ) + def _build_content(self) -> str: theme = get_active_theme() dim = theme.text_dim.hex @@ -88,7 +107,11 @@ lines: list[str] = [ f"[{theme.success.hex}]{done}[/{theme.success.hex}]/{n} done" ] - for t in self._tasks: + # Display order: active work on top, queued next, blocked/dismissed + # (failed + skipped) below the queue, completed at the bottom. Stable + # within a group — original payload order is preserved, only the groups + # are reordered. ``self._tasks`` itself keeps the plan's original order. + for t in self._ordered_tasks(): 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 diff --git a/tests/clients/test_todo_list.py b/tests/clients/test_todo_list.py index 88947bc..e161ac3 100644 --- a/tests/clients/test_todo_list.py +++ b/tests/clients/test_todo_list.py @@ -123,6 +123,40 @@ @pytest.mark.anyio +async def test_todo_list_groups_by_status_in_progress_first_done_last() -> None: + """Steps are grouped for display: in_progress on top, then pending, then + failed/skipped, then done at the bottom — regardless of the payload order. + Original order is preserved within each group; the header still counts all + done steps.""" + from clients.terminal.tui.tui_app import NaviCodeTui + + # Payload in plan order: done, pending, in_progress, done, failed, skipped. + tasks = [ + {"index": 1, "text": "done one", "status": "done", "validation": ""}, + {"index": 2, "text": "queued one", "status": "pending", "validation": ""}, + {"index": 3, "text": "active one", "status": "in_progress", "validation": ""}, + {"index": 4, "text": "done two", "status": "done", "validation": ""}, + {"index": 5, "text": "boom", "status": "failed", "validation": ""}, + {"index": 6, "text": "skip", "status": "skipped", "validation": ""}, + ] + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + todo = pilot.app.query_one(TodoList) + todo.set_tasks(tasks) + plain = _plain(todo._build_content()) + # Header counts both done steps. + assert "2/6 done" in plain + # The visible order is the display order, not the payload order. + assert plain.index("active one") < plain.index("queued one") + assert plain.index("queued one") < plain.index("boom") + assert plain.index("boom") < plain.index("done one") + assert plain.index("skip") < plain.index("done one") + assert plain.index("done one") < plain.index("done two") + # Original order preserved within the done group (done one before done two). + assert plain.index("done one") < plain.index("done two") + + +@pytest.mark.anyio async def test_todo_panel_is_scrollable_and_wraps_list() -> None: """The todo lives in its own scrollable panel below the info block, not inside StatusPanel."""