Coverage for src / lilbee / cli / tui / widgets / task_row.py: 100%
67 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""Single-task row widget for the Task Center.
3Three lines per row. The head line uses the same ``pill()`` treatment
4as the model cards so the screen matches the rest of the app; the
5left rail carries the state color and pulses at 1 Hz for the active
6row. The widget is pure-presentation: ``update(task, tick)`` writes
7the three labels from a ``Task`` snapshot. ``TaskCenter._poll`` calls
8it at 10 Hz.
9"""
11from __future__ import annotations
13from time import monotonic
15from textual.app import ComposeResult
16from textual.content import Content
17from textual.widget import Widget
18from textual.widgets import Label, Static
20from lilbee.cli.tui.pill import pill
21from lilbee.cli.tui.task_queue import Task, TaskStatus, TaskType
22from lilbee.cli.tui.widgets.progress_cell import (
23 frozen_indeterminate_cell,
24 indeterminate_cell,
25 progress_cell,
26)
28# ~1.7 Hz rail pulse at a 10 Hz poll cadence = 3 ticks on, 3 off.
29# Faster cadence than the original 1 Hz makes 'something is happening'
30# visibly obvious at a glance (bb-18y3).
31_PULSE_HALF_TICKS = 3
33_STATUS_CLASS: dict[TaskStatus, str] = {
34 TaskStatus.QUEUED: "-queued",
35 TaskStatus.ACTIVE: "-active",
36 TaskStatus.DONE: "-done",
37 TaskStatus.FAILED: "-failed",
38 TaskStatus.CANCELLED: "-cancelled",
39}
41_STATUS_CLASSES: tuple[str, ...] = tuple(_STATUS_CLASS.values())
43# Pill palette — background color per task type. Sync/add/remove share
44# $secondary (data-mutating ops), download uses $accent (network), wiki
45# uses $warning (CPU-heavy generation), crawl uses $primary (external).
46_TASK_TYPE_BG: dict[str, str] = {
47 TaskType.DOWNLOAD.value: "$accent",
48 TaskType.SYNC.value: "$secondary",
49 TaskType.ADD.value: "$secondary",
50 TaskType.REMOVE.value: "$secondary",
51 TaskType.CRAWL.value: "$primary",
52 TaskType.WIKI.value: "$warning",
53 TaskType.SETUP.value: "$warning-darken-1",
54}
55_TASK_TYPE_BG_FALLBACK = "$primary"
57# Pill palette — status badge. QUEUED is muted so only the running ones
58# pop; DONE / FAILED / CANCELLED use brightened backgrounds so terminal
59# states stand out against the matching left-rail color.
60_STATUS_BG: dict[TaskStatus, str] = {
61 TaskStatus.QUEUED: "$surface-lighten-2",
62 TaskStatus.ACTIVE: "$primary",
63 TaskStatus.DONE: "$success-lighten-2",
64 TaskStatus.FAILED: "$error-lighten-2",
65 TaskStatus.CANCELLED: "$warning-lighten-2",
66}
69_TERMINAL_STATUSES: frozenset[TaskStatus] = frozenset(
70 {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
71)
74def _build_head(task: Task, elapsed: str) -> Content:
75 """Build the top line: name + type pill + status pill, elapsed trailing.
77 Kept as a module-level helper so tests can exercise the pill
78 composition directly without spinning up the full widget tree.
79 """
80 type_bg = _TASK_TYPE_BG.get(task.task_type, _TASK_TYPE_BG_FALLBACK)
81 status_bg = _STATUS_BG[task.status]
82 status_fg = "$text bold" if task.status in _TERMINAL_STATUSES else "$text"
83 parts = [
84 Content.styled(task.name, "bold"),
85 Content(" "),
86 pill(task.task_type, type_bg, "$text"),
87 Content(" "),
88 pill(task.status.value, status_bg, status_fg),
89 ]
90 if elapsed:
91 parts.append(Content(" "))
92 parts.append(Content.styled(elapsed, "dim"))
93 return Content.assemble(*parts)
96def _format_elapsed(task: Task) -> str:
97 """Return elapsed time as MM:SS, a status tag, or empty.
99 Terminal states (DONE / FAILED / CANCELLED) freeze at ``completed_at``
100 so the timer doesn't keep climbing for rows that are just waiting out
101 their 2-second flash before removal.
102 """
103 if task.status == TaskStatus.QUEUED:
104 return "queued"
105 if task.started_at is None:
106 return ""
107 end = task.completed_at if task.completed_at is not None else monotonic()
108 seconds = max(0, int(end - task.started_at))
109 mm, ss = divmod(seconds, 60)
110 return f"{mm:02d}:{ss:02d}"
113class TaskRow(Widget, can_focus=True):
114 """One task, rendered as three stacked lines.
116 Focusable so ``Tab`` / ``j`` / ``k`` in the Task Center moves between
117 rows and ``c`` cancels the focused task.
118 """
120 DEFAULT_CSS = "" # all styling lives in task_center.tcss
122 def __init__(self, task_id: str, **kwargs: object) -> None:
123 super().__init__(id=f"task-{task_id}", **kwargs) # type: ignore[arg-type]
124 self._task_id = task_id
126 def compose(self) -> ComposeResult:
127 # Widget with yielded children lays them out vertically by default.
128 # An explicit Vertical wrapper would inherit ``height: 1fr`` and
129 # stretch each row to fill the scroll viewport, painting the
130 # border-left rail down the whole empty stretch.
131 yield Label("", id="row-head", classes="row-head")
132 yield Label("", id="row-meta", classes="row-meta")
133 yield Static("", id="row-bar", classes="row-bar")
135 def update(self, task: Task, tick: int) -> None:
136 """Re-render from a Task snapshot. Safe to call every poll tick.
138 Quietly no-ops until the row's child labels have mounted, so the
139 first few poll ticks (before compose settles) don't error.
140 """
141 # State class: exactly one of the 5 modifier classes is active.
142 target_class = _STATUS_CLASS.get(task.status, "")
143 for cls in _STATUS_CLASSES:
144 self.set_class(cls == target_class, cls)
145 # 1 Hz rail pulse on the active row only.
146 self.set_class(
147 task.status == TaskStatus.ACTIVE and (tick // _PULSE_HALF_TICKS) % 2 == 0,
148 "-pulse",
149 )
151 try:
152 head = self.query_one("#row-head", Label)
153 meta = self.query_one("#row-meta", Label)
154 bar = self.query_one("#row-bar", Static)
155 except Exception:
156 return # compose hasn't finished; retry on next poll
158 elapsed = _format_elapsed(task)
159 head.update(_build_head(task, elapsed))
161 detail = task.detail or ""
162 pct = "" if task.indeterminate else f"[b]{task.progress:.1f}%[/b]"
163 meta_parts = [detail, pct]
164 meta.update(" ".join(p for p in meta_parts if p))
166 if task.indeterminate:
167 # Terminal rows freeze the bar so a cancelled/failed/done
168 # task doesn't keep reading as live work.
169 if task.status in _TERMINAL_STATUSES:
170 bar.update(frozen_indeterminate_cell())
171 else:
172 bar.update(indeterminate_cell(tick))
173 else:
174 bar.update(progress_cell(task.progress))
176 def flash_completed(self) -> None:
177 """Mark the row as 'just completed' for a 2-second visual flash."""
178 self.add_class("-just-completed")