Coverage for src/lilbee/cli/tui/widgets/task_bar.py: 100%

194 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

1"""TaskBar widget: slim 1-line status indicator polling the shared TaskQueue.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import logging 

7from collections.abc import Callable 

8from pathlib import Path 

9from typing import TYPE_CHECKING, ClassVar 

10 

11from textual.app import ComposeResult 

12from textual.timer import Timer 

13from textual.widgets import Label, Static 

14 

15from lilbee.catalog.formatting import display_label_for_ref 

16from lilbee.cli.tui import messages as msg 

17from lilbee.cli.tui.task_queue import TaskQueue, TaskStatus 

18from lilbee.cli.tui.widgets.task_bar_controller import TaskBarController 

19from lilbee.providers.warm_progress import WarmPhase, WarmProgress 

20 

21if TYPE_CHECKING: 

22 from lilbee.cli.tui.app import LilbeeApp 

23 

24log = logging.getLogger(__name__) 

25 

26_CSS_FILE = Path(__file__).parent / "task_bar.tcss" 

27 

28_DONE_FLASH_SECONDS = 2.0 

29_POLL_INTERVAL_ACTIVE_S = 0.1 

30_POLL_INTERVAL_IDLE_S = 1.0 

31 

32# Pulsing-dot cadence: on/off flip at half of this tick count. 

33# 10 Hz poll x 5 = 500 ms per half cycle, which is a 1 Hz dot pulse, 

34# matching the active-row rail pulse in the Task Center. 

35_DOT_PULSE_HALF_TICKS = 5 

36_DOT_GLYPH = "●" 

37 

38# Warm bar width; fill/track glyphs come from msg.progress_bar_glyphs() at 

39# render time, matching the fleet panel bars. 

40_WARM_BAR_WIDTH = 12 

41 

42 

43# Indeterminate sweep (the engine-load phases have no byte signal): a lit window 

44# that walks the track so the bar reads as "working", not stalled. Advanced by the 

45# poll tick. Preferred over a spinner glyph: the moving bar reads as progress at a 

46# glance, which is what a multi-second model load needs. 

47_WARM_SWEEP_WIDTH = 3 

48 

49 

50def _progress_bar(fraction: float) -> str: 

51 """A determinate fill bar for the byte-progress (reading-weights) phase.""" 

52 fill, track = msg.progress_bar_glyphs() 

53 filled = round(max(0.0, min(1.0, fraction)) * _WARM_BAR_WIDTH) 

54 return fill * filled + track * (_WARM_BAR_WIDTH - filled) 

55 

56 

57def _sweep_bar(tick: int) -> str: 

58 """An indeterminate bar with a lit window of fixed width walking (and wrapping) 

59 across the track, keyed to *tick*, so it always shows motion, never a blank.""" 

60 fill, track = msg.progress_bar_glyphs() 

61 start = tick % _WARM_BAR_WIDTH 

62 lit = {(start + offset) % _WARM_BAR_WIDTH for offset in range(_WARM_SWEEP_WIDTH)} 

63 return "".join(fill if i in lit else track for i in range(_WARM_BAR_WIDTH)) 

64 

65 

66def _warm_detail(progress: WarmProgress | None, tick: int = 0) -> str | None: 

67 """A moving bar plus the phase word for the warm line. 

68 

69 A determinate byte bar while paging weights, an indeterminate sweep while the 

70 engine starts and loads (no byte signal). None once past an active phase, so 

71 the caller drops the line entirely instead of showing a stalled indicator. 

72 """ 

73 if progress is None: 

74 return None 

75 if progress.phase is WarmPhase.STARTING: 

76 return f"{_sweep_bar(tick)} {msg.TASKBAR_WARM_STARTING}" 

77 if progress.phase is WarmPhase.LOADING_ENGINE: 

78 return f"{_sweep_bar(tick)} {msg.TASKBAR_WARM_LOADING}" 

79 if progress.phase is WarmPhase.READING_WEIGHTS: 

80 fraction = progress.bytes_done / progress.bytes_total if progress.bytes_total else 0.0 

81 pct = int(fraction * 100) 

82 return f"{_progress_bar(fraction)} {msg.TASKBAR_WARM_READING.format(pct=pct)}" 

83 return None 

84 

85 

86class TaskBar(Static): 

87 """Slim 1-line status indicator for background tasks. 

88 

89 Shows a compact summary when tasks are active and hides when idle. 

90 Detailed progress (spinners, progress bars, task panels) lives in 

91 the Task Center screen, accessible via ``t``. 

92 """ 

93 

94 app: LilbeeApp # type: ignore[assignment] 

95 

96 # NOTE: no ``dock: bottom`` here. TaskBar is always mounted inside a 

97 # ``BottomBars`` container that owns the dock; multiple dock-bottom 

98 # siblings overlap at the same row in Textual (see BottomBars docstring). 

99 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") 

100 

101 def __init__(self, **kwargs: object) -> None: 

102 super().__init__(**kwargs) # type: ignore[arg-type] 

103 self._tick_count = 0 

104 # Timestamp (tick count) at which the current flash started. 

105 # None when no flash is active. The 2 s completion/failure 

106 # flash holds the coloured dot + summary past queue drain. 

107 self._flash_until_tick: int | None = None 

108 self._flash_outcome: TaskStatus | None = None 

109 # Failures among the just-finished batch (not all of persistent history). 

110 self._flash_failed_count: int = 0 

111 # Task ids we've already flashed on. Task Center rows linger in 

112 # history after DONE/FAILED/CANCELLED so the user can review 

113 # recent work; without this gate the bar would re-flash the same 

114 # task every poll because ``history[-1]`` keeps matching. 

115 self._flashed_ids: set[str] = set() 

116 # Fingerprint of the most recently painted label state. Each 

117 # tick fires at 10 Hz; if nothing visible has changed (no new 

118 # tasks, no progress shift, no pulse-phase flip) the heavy 

119 # ``Label.update`` -- which re-segments + re-styles the line -- 

120 # is skipped. Visible idle cost drops from "every tick" to "on 

121 # actual change", recovering ~5-8 ms/sec on idle screens. 

122 self._last_render_fingerprint: tuple[object, ...] | None = None 

123 # Poll handle. Set in on_mount and cleared in on_unmount; declared 

124 # here so on_unmount can read it directly without a getattr fallback. 

125 self._interval: Timer | None = None 

126 # True when no work is visible: the timer drops to 1 Hz here since 

127 # the fingerprint cache short-circuits the render path. Flips 

128 # back on the first non-idle event. 

129 self._idle_mode: bool = True 

130 

131 def compose(self) -> ComposeResult: 

132 yield Label("", id="task-status-label") 

133 

134 def on_mount(self) -> None: 

135 self._refresh_display() 

136 # Capture the handle so we can cancel the poll on unmount. Without 

137 # this, a screen push/pop cycle leaves the previous TaskBar's 

138 # interval firing against a detached widget, racing with the new 

139 # TaskBar and occasionally setting ``display=False`` on the live 

140 # instance. Start at the idle cadence; the first tick re-arms at 

141 # 10 Hz if work is already in flight. 

142 self._interval = self.set_interval(_POLL_INTERVAL_IDLE_S, self._tick) 

143 

144 def on_unmount(self) -> None: 

145 if self._interval is not None: 

146 self._interval.stop() 

147 self._interval = None 

148 

149 @property 

150 def _controller(self) -> TaskBarController: 

151 return self.app.task_bar 

152 

153 @property 

154 def queue(self) -> TaskQueue: 

155 """Expose the shared queue for callers that iterate or advance it.""" 

156 return self._controller.queue 

157 

158 def add_task( 

159 self, 

160 name: str, 

161 task_type: str, 

162 fn: Callable[[], None] | None = None, 

163 *, 

164 indeterminate: bool = False, 

165 ) -> str: 

166 """Enqueue a task via the app's controller. Returns the task_id.""" 

167 return self._controller.add_task(name, task_type, fn, indeterminate=indeterminate) 

168 

169 def update_task( 

170 self, 

171 task_id: str, 

172 progress: float, 

173 detail: str = "", 

174 *, 

175 indeterminate: bool | None = None, 

176 ) -> None: 

177 self._controller.update_task(task_id, progress, detail, indeterminate=indeterminate) 

178 

179 def complete_task(self, task_id: str) -> None: 

180 self._controller.complete_task(task_id) 

181 

182 def fail_task(self, task_id: str, detail: str = "") -> None: 

183 self._controller.fail_task(task_id, detail) 

184 

185 def cancel_task(self, task_id: str) -> None: 

186 self._controller.cancel_task(task_id) 

187 

188 def _tick(self) -> None: 

189 """Poll the shared queue and re-render.""" 

190 self._tick_count += 1 

191 self._refresh_display() 

192 

193 def _sync_poll_cadence(self, fully_idle: bool) -> None: 

194 """Re-arm the poll timer at idle/active cadence on state transitions.""" 

195 if fully_idle == self._idle_mode: 

196 return 

197 self._idle_mode = fully_idle 

198 if self._interval is not None: 

199 self._interval.stop() 

200 interval = _POLL_INTERVAL_IDLE_S if fully_idle else _POLL_INTERVAL_ACTIVE_S 

201 self._interval = self.set_interval(interval, self._tick) 

202 

203 def _refresh_display(self) -> None: 

204 """Rebuild the 1-line status label from the shared queue. 

205 

206 Visual language: 

207 - Leading ``●`` pulses ``$primary`` <-> ``$primary-lighten-2`` at 1 Hz 

208 when anything is active. Dim ``$text-muted`` when only queued tasks 

209 remain, ``$success`` during a completion flash, ``$error`` during 

210 a failure flash. 

211 - The text either reads ``{name} {pct}`` (one active, zero queued), 

212 ``{N} tasks running`` (plural), ``{N} queued`` (throttle mode), 

213 or the flash copy. 

214 - Right-aligned muted-italic ``Press t for Tasks`` hint. 

215 """ 

216 queue = self.queue 

217 active = queue.active_tasks 

218 queued = queue.queued_tasks 

219 history = queue.history 

220 

221 # Drop flashed-id entries for tasks the user has cleared from 

222 # history. Without this prune, the set grows unbounded over a 

223 # long session even though any id not in history can't re-flash. 

224 if self._flashed_ids: 

225 live_ids = {t.task_id for t in history} 

226 self._flashed_ids &= live_ids 

227 

228 in_flash = self._flash_until_tick is not None and self._tick_count <= self._flash_until_tick 

229 if not in_flash: 

230 self._flash_until_tick = None 

231 self._flash_outcome = None 

232 # Flash on the freshest completion that hasn't been flashed 

233 # yet. History now persists (rows show as DONE in Task 

234 # Center until cleared), so we must gate by task_id instead 

235 # of "history is non-empty". 

236 if not active and not queued and history: 

237 new_done = [ 

238 t 

239 for t in history 

240 if t.task_id not in self._flashed_ids 

241 and t.status in (TaskStatus.DONE, TaskStatus.FAILED) 

242 ] 

243 if new_done: 

244 for t in new_done: 

245 self._flashed_ids.add(t.task_id) 

246 self._flash_until_tick = self._tick_count + int( 

247 _DONE_FLASH_SECONDS / _POLL_INTERVAL_ACTIVE_S 

248 ) 

249 self._flash_failed_count = sum( 

250 1 for t in new_done if t.status == TaskStatus.FAILED 

251 ) 

252 self._flash_outcome = ( 

253 TaskStatus.FAILED if self._flash_failed_count else TaskStatus.DONE 

254 ) 

255 

256 idle = not active and not queued and not in_flash and self._flash_outcome is None 

257 pending = self._controller.pending_sync_count if idle else 0 

258 spawning_roles = sorted(self._controller.spawning_roles) if idle else [] 

259 # Computed regardless of task activity: a chat warm holds the user's input 

260 # disabled, so it outranks a passive task summary (which stays in the Task 

261 # Center). Hiding it behind a background sync left a dead input unexplained. 

262 warm_line = self._warm_line() 

263 fully_idle = idle and pending == 0 and not spawning_roles and warm_line is None 

264 self._sync_poll_cadence(fully_idle) 

265 if fully_idle: 

266 self.display = False 

267 self._last_render_fingerprint = None 

268 return 

269 

270 self.display = True 

271 dot_color, summary = self._status_line( 

272 active, queued, spawning_roles, pending, warm_line, idle=idle 

273 ) 

274 hint_text = self._hint_copy() 

275 # Fingerprint captures every variable the label content depends 

276 # on. Recomputing it is essentially free; the win comes from 

277 # skipping ``Label.update`` when nothing visible has changed, 

278 # since update re-segments and re-styles the whole line. 

279 fingerprint: tuple[object, ...] = ( 

280 dot_color, 

281 summary, 

282 hint_text, 

283 in_flash, 

284 self._flash_outcome, 

285 pending, 

286 tuple(spawning_roles), 

287 warm_line, 

288 ) 

289 if fingerprint == self._last_render_fingerprint: 

290 return 

291 self._last_render_fingerprint = fingerprint 

292 

293 label_text = f" [{dot_color}]{_DOT_GLYPH}[/] {summary} [i dim]{hint_text}[/]" 

294 with contextlib.suppress(Exception): 

295 label = self.query_one("#task-status-label", Label) 

296 label.update(label_text) 

297 

298 def _status_line( 

299 self, 

300 active: list, # type: ignore[type-arg] 

301 queued: list, # type: ignore[type-arg] 

302 spawning_roles: list[str], 

303 pending: int, 

304 warm_line: str | None, 

305 *, 

306 idle: bool, 

307 ) -> tuple[str, str]: 

308 """Pick the dot color and summary text for the current bar state.""" 

309 if warm_line is not None: 

310 return "$primary", warm_line 

311 if idle and spawning_roles: 

312 return "$primary", self._spawning_workers_template(spawning_roles) 

313 if idle and pending > 0: 

314 return "$text-muted", self._pending_sync_template(pending).format(count=pending) 

315 return self._compose_segments(active, queued) 

316 

317 def _warm_line(self) -> str | None: 

318 """The cold-start chat warm line, or None when chat isn't warming.""" 

319 from lilbee.app.placement import active_chat_warm_progress 

320 

321 progress = active_chat_warm_progress() 

322 detail = _warm_detail(progress, self._tick_count) 

323 if detail is None: 

324 return None 

325 name = ( 

326 display_label_for_ref(progress.model_ref) 

327 if progress is not None and progress.model_ref 

328 else msg.TASKBAR_WARM_FALLBACK_NAME 

329 ) 

330 return msg.TASKBAR_WARM_LINE.format(name=name, detail=detail) 

331 

332 def _spawning_workers_template(self, roles: list[str]) -> str: 

333 """Render the active worker-warmup hint for the bottom bar.""" 

334 labels = ", ".join(role.replace("_", " ") for role in roles) 

335 template = msg.TASKBAR_STARTING_WORKER if len(roles) == 1 else msg.TASKBAR_STARTING_WORKERS 

336 return template.format(labels=labels) 

337 

338 def _pending_sync_template(self, pending: int) -> str: 

339 """Pick the singular/plural hint, swapping in the Esc-prefixed copy 

340 when a chat ``Input`` swallows printable characters before bindings fire. 

341 """ 

342 from textual.widgets import Input 

343 

344 try: 

345 input_focused = isinstance(self.app.focused, Input) 

346 except Exception: 

347 input_focused = False 

348 if pending == 1: 

349 return ( 

350 msg.TASKBAR_SYNC_PENDING_ONE_INPUT 

351 if input_focused 

352 else msg.TASKBAR_SYNC_PENDING_ONE 

353 ) 

354 return ( 

355 msg.TASKBAR_SYNC_PENDING_PLURAL_INPUT 

356 if input_focused 

357 else msg.TASKBAR_SYNC_PENDING_PLURAL 

358 ) 

359 

360 def _hint_copy(self) -> str: 

361 """Return the right-aligned hint, context-aware. 

362 

363 When a chat ``Input`` (or similar) is focused the ``t`` keypress is 

364 eaten before the app-level binding fires, so the user needs 

365 ``Esc then t``. Every other screen (wizard grid, catalog, 

366 settings, task center) lets ``t`` bubble, so a shorter ``Press t 

367 for Tasks`` is accurate and easier to scan. 

368 """ 

369 from textual.widgets import Input 

370 

371 try: 

372 focused = self.app.focused 

373 except Exception: 

374 return msg.TASKBAR_HINT 

375 if isinstance(focused, Input): 

376 return msg.TASKBAR_HINT_INPUT 

377 return msg.TASKBAR_HINT 

378 

379 def _compose_segments(self, active: list, queued: list) -> tuple[str, str]: 

380 """Return (dot color, text summary) for the current state.""" 

381 # Pulsing even/odd cadence, shared with TaskRow's rail pulse. 

382 on_beat = (self._tick_count // _DOT_PULSE_HALF_TICKS) % 2 == 0 

383 

384 if self._flash_outcome == TaskStatus.DONE: 

385 return "$success", msg.TASKBAR_ALL_DONE 

386 if self._flash_outcome == TaskStatus.FAILED: 

387 count = self._flash_failed_count 

388 key = msg.TASKBAR_FAILED if count == 1 else msg.TASKBAR_FAILED_PLURAL 

389 return "$error", key.format(count=count) 

390 

391 parts: list[str] = [] 

392 if active: 

393 count = len(active) 

394 task = active[0] 

395 if count == 1 and not queued: 

396 pct = "" if task.indeterminate else f" [b]{task.progress:.1f}%[/b]" 

397 parts.append(f"[b]{task.name}[/b]{pct}") 

398 else: 

399 key = msg.TASKBAR_ONE if count == 1 else msg.TASKBAR_MULTIPLE 

400 parts.append(key.format(count=count)) 

401 parts.append(f"[b]{task.name}[/b]") 

402 if queued: 

403 parts.append(f"[dim]{msg.TASKBAR_QUEUED_COUNT.format(count=len(queued))}[/dim]") 

404 

405 dot_color = ("$primary" if on_beat else "$primary-lighten-2") if active else "$text-muted" 

406 return dot_color, " · ".join(parts)