diff --git a/clients/terminal/tui/widgets/todo_list.py b/clients/terminal/tui/widgets/todo_list.py index 488de36..36394c3 100644 --- a/clients/terminal/tui/widgets/todo_list.py +++ b/clients/terminal/tui/widgets/todo_list.py @@ -107,9 +107,28 @@ def _has_milestones(self) -> bool: return any((t.get("milestone") or "") for t in self._tasks) + def _group_rank(self, tasks: list[dict]) -> int: + """Lowest status-rank among a group's tasks — the group's *most active* + step. Used to order groups the same way steps are ordered (active + groups up, fully-done groups down). An empty group sinks like a + fully-done one (rank 3).""" + if not tasks: + return self._STATUS_RANK["done"] + return min( + self._STATUS_RANK.get(t.get("status", "") or "pending", 1) + for t in tasks + ) + def _milestone_groups(self) -> list[tuple[str, list[dict]]]: - """Group tasks by ``milestone`` in first-appearance order. The unnamed - group (``""``) is moved to the end so named milestones lead the panel.""" + """Group tasks by ``milestone`` and order groups by their most active + step's status-rank (in_progress → pending → failed/skipped → fully + done), so a finished milestone sinks below one with work in progress — + the same "done to the bottom" rule that applies to individual steps. + Python's sort is stable, so groups of equal rank keep the plan's + first-appearance order. The unnamed group (``""``) takes part in the + same ranking and renders without a header; it no longer forced to the + end, so an active unnamed block can lead the panel while finished + named milestones sink.""" order: list[str] = [] by_group: dict[str, list[dict]] = {} for t in self._tasks: @@ -118,10 +137,9 @@ order.append(ms) by_group[ms] = [] by_group[ms].append(t) - if "" in by_group and order[-1] != "": - order.remove("") - order.append("") - return [(g, by_group[g]) for g in order] + groups = [(g, by_group[g]) for g in order] + groups.sort(key=lambda gv: self._group_rank(gv[1])) + return groups def _render_task_line(self, t: dict, theme, dim: str) -> str: status = t.get("status", "") or "pending" @@ -159,8 +177,10 @@ lines.append(self._render_task_line(t, theme, dim)) return "\n".join(lines) # Grouped path: milestone headers (✓ when the whole group is done/ - # skipped), status-rank ordering within each group. Plan order defines - # group sequence; the unnamed group goes last. + # skipped), status-rank ordering within each group. Groups are ordered + # by their most active step (see _milestone_groups), so finished + # milestones sink below ones with work in progress; the unnamed group + # renders without a header wherever its rank places it. for ms, group_tasks in self._milestone_groups(): if ms: group_done = all( diff --git a/tests/clients/test_todo_list.py b/tests/clients/test_todo_list.py index a4c3698..f43ea7f 100644 --- a/tests/clients/test_todo_list.py +++ b/tests/clients/test_todo_list.py @@ -159,8 +159,10 @@ @pytest.mark.anyio async def test_groups_tasks_by_milestone() -> None: """When tasks carry a ``milestone`` label, the panel renders milestone - headers and nests each step under its group. Plan order defines group - sequence; the unnamed group goes last.""" + headers and nests each step under its group. Groups are ordered by their + most active step's status-rank (with a stable tiebreak on first-appearance + order); the unnamed group takes part in the same ranking and renders + without a header.""" from clients.terminal.tui.tui_app import NaviCodeTui tasks = [ @@ -174,20 +176,94 @@ todo = pilot.app.query_one(TodoList) todo.set_tasks(tasks) plain = _plain(todo._build_content()) - # Both milestone headers present, in plan order. + # Both milestone headers present. assert "Milestone A" in plain assert "Milestone B" in plain + # Ranks: A=0 (in_progress), B=1 (pending), ""=1 (pending). Stable + # tiebreak keeps first-appearance order among equal ranks, so A, then B, + # then the unnamed group. assert plain.index("Milestone A") < plain.index("Milestone B") # Steps nest under their group (group header precedes its steps). assert plain.index("Milestone A") < plain.index("write code") assert plain.index("Milestone B") < plain.index("run tests") - # The unnamed group ("orphan step") goes last, after the named groups. + # The unnamed group ("orphan step") follows the named groups of equal + # or higher activity (here B is also pending, so it keeps plan position + # ahead of the unnamed block via the stable tiebreak). assert plain.index("run tests") < plain.index("orphan step") # Header still counts all done steps. assert "1/4 done" in plain @pytest.mark.anyio +async def test_milestone_groups_ordered_by_rank_done_groups_sink() -> None: + """A fully-done milestone sinks below one with work in progress, extending + the per-step "done to the bottom" rule to the group level — regardless of + plan order in the payload.""" + from clients.terminal.tui.tui_app import NaviCodeTui + + # Payload in plan order A (done), B (active), C (pending). + tasks = [ + {"index": 1, "text": "A done 1", "status": "done", "validation": "", "milestone": "A"}, + {"index": 2, "text": "A done 2", "status": "done", "validation": "", "milestone": "A"}, + {"index": 3, "text": "B active", "status": "in_progress", "validation": "", "milestone": "B"}, + {"index": 4, "text": "B pending", "status": "pending", "validation": "", "milestone": "B"}, + {"index": 5, "text": "C pending", "status": "pending", "validation": "", "milestone": "C"}, + ] + 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()) + # Ranks: A=3 (all done), B=0 (in_progress), C=1 (pending). + # Expected group order: B (active) -> C (pending) -> A (done, sunk). + assert plain.index("Milestone B") < plain.index("Milestone C") + assert plain.index("Milestone C") < plain.index("Milestone A") + # The done group A carries the ✓ marker (it is fully done). + a_line = next(line for line in todo._build_content().splitlines() if "Milestone A" in line) + assert "✓" in a_line + + +@pytest.mark.anyio +async def test_unnamed_group_ranks_with_named_groups() -> None: + """An unnamed (no-milestone) step block ranks alongside named groups by its + own activity: an active unnamed block leads the panel while a finished + named milestone sinks below it. The unnamed block renders without a + header.""" + from clients.terminal.tui.tui_app import NaviCodeTui + + tasks = [ + {"index": 1, "text": "A done", "status": "done", "validation": "", "milestone": "A"}, + {"index": 2, "text": "orphan active", "status": "in_progress", "validation": "", "milestone": ""}, + ] + 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()) + # Ranks: ""=0 (in_progress), A=3 (done). Unnamed block leads. + assert plain.index("orphan active") < plain.index("Milestone A") + + +@pytest.mark.anyio +async def test_milestone_groups_equal_rank_keep_plan_order() -> None: + """Groups of equal rank keep the plan's first-appearance order (stable + sort), so two equally-inactive milestones are not shuffled arbitrarily.""" + from clients.terminal.tui.tui_app import NaviCodeTui + + tasks = [ + {"index": 1, "text": "A pending", "status": "pending", "validation": "", "milestone": "A"}, + {"index": 2, "text": "B pending", "status": "pending", "validation": "", "milestone": "B"}, + ] + 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()) + # Both rank 1 (pending); stable tiebreak keeps plan order A -> B. + assert plain.index("Milestone A") < plain.index("Milestone B") + + +@pytest.mark.anyio async def test_milestone_group_marked_done_when_all_steps_done() -> None: """A milestone group whose steps are all done/skipped gets a ✓ on its header; an in-progress group does not."""