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

318 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""FleetBody: the GPU table, live panel, and interactive placement editor. 

2 

3Reusable widget that can be mounted both as the top-level Fleet view and as 

4a modal overlay. 

5""" 

6 

7from __future__ import annotations 

8 

9import contextlib 

10import logging 

11import re 

12from collections.abc import Callable 

13from dataclasses import dataclass 

14from pathlib import Path 

15from typing import TYPE_CHECKING, ClassVar 

16 

17from textual import events, work 

18from textual.app import ComposeResult 

19from textual.binding import Binding, BindingType 

20from textual.containers import Horizontal, Vertical 

21from textual.css.query import NoMatches 

22from textual.message import Message 

23from textual.reactive import reactive 

24from textual.widget import Widget 

25from textual.widgets import Label, Static 

26 

27from lilbee.app.placement import ( 

28 get_placement, 

29 preview_placement, 

30 set_placement, 

31 wait_chat_ready, 

32) 

33from lilbee.cli.tui import messages as msg 

34from lilbee.cli.tui.thread_safe import call_from_thread 

35from lilbee.cli.tui.widgets.gpu_fleet_panel import GpuFleetPanel 

36from lilbee.providers.fleet.placement_spec import PlacementError, PlacementSpec, RolePlacement 

37from lilbee.providers.roles import REPLICATED_ROLES, WorkerRole 

38 

39if TYPE_CHECKING: 

40 from lilbee.app.placement import PlacementView 

41 

42log = logging.getLogger(__name__) 

43 

44_GGUF_SHARD_RE = re.compile(r"-\d{5}-of-\d{5}$") 

45_GGUF_QUANT_RE = re.compile(r"-(?:Q\d[\w.]*|IQ\d[\w.]*|F16|BF16|FP16|F32)$", re.IGNORECASE) 

46_GGUF_REPO_SUFFIXES = ("-GGUF", "-gguf") 

47 

48 

49def _clean_model_name(ref: str) -> str: 

50 """Short, human-friendly model name from a GGUF reference. 

51 

52 "Qwen/Qwen3-235B-A22B-GGUF/Q4_K_M/...-00001-of-00005.gguf" -> "Qwen3-235B-A22B". 

53 """ 

54 parts = ref.split("/") 

55 for component in parts[1:]: # skip the org; the repo name is the friendly one 

56 if component.endswith(_GGUF_REPO_SUFFIXES): 

57 return component.rsplit("-", 1)[0] 

58 stem = parts[-1].removesuffix(".gguf") 

59 stem = _GGUF_SHARD_RE.sub("", stem) 

60 return _GGUF_QUANT_RE.sub("", stem) 

61 

62 

63_CSS_FILE = Path(__file__).parent / "fleet_body.tcss" 

64 

65_EDITOR_ID = "#placement-editor" 

66_SKIPPED_ID = "#placement-skipped" 

67_TITLE_ID = "#placement-title" 

68_STATE_ID = "#placement-state" 

69_COMMANDS_ID = "#placement-commands" 

70_HINT_WIDGET_ID = "#placement-hint" 

71_FLEET_PANEL_ID = "#gpu-fleet-panel" 

72 

73# Only the replicated roles show a replica stepper; the others always serve one. 

74_REPLICA_ROLES = REPLICATED_ROLES 

75_HINT = ( 

76 "Toggle a GPU for each role; -/+ sets replicas. ctrl+r preview · ctrl+s apply · ctrl+x auto" 

77) 

78_CMD_PREVIEW = "cmd-preview" 

79_CMD_APPLY = "cmd-apply" 

80_CMD_AUTO = "cmd-auto" 

81# GPUs shown per page in the placement grid; more than this paginate. 

82_PLACEMENT_PAGE_SIZE = 8 

83# rerank is a single pinned instance on one card (a small cross-encoder that never 

84# tensor-splits), so its GPU choice is single-select, unlike the multi-GPU roles. 

85_SINGLE_ROLES = (WorkerRole.RERANK,) 

86# Editor row order: the multi-GPU roles first, rerank last -- the single-card odd 

87# one out sits at the bottom instead of between the replicated roles. 

88_EDITOR_ROLE_ORDER = (WorkerRole.CHAT, WorkerRole.EMBED, WorkerRole.VISION, WorkerRole.RERANK) 

89 

90 

91def _role_kind(role: WorkerRole) -> str: 

92 """How a role occupies GPUs: 'mirror' (a copy per card), 'single' (one pinned 

93 card), or 'split' (one model tensor-split across cards).""" 

94 if role in _REPLICA_ROLES: 

95 return "mirror" 

96 if role in _SINGLE_ROLES: 

97 return "single" 

98 return "split" 

99 

100 

101class FleetPill(Static, can_focus=True): 

102 """Focusable one-line pill; Enter / Space / click presses it. 

103 

104 The editor's toggles, steppers, pager, and command controls are all pills 

105 (the ``Static, can_focus=True`` + bindings pattern from 

106 ``widgets/confirm_dialog.py`` / ``model_bar.py::ChatModePill``): state and 

107 focus ride the fill and text style, so a row costs one line instead of the 

108 three rows of Button chrome. 

109 """ 

110 

111 class Pressed(Message): 

112 """Posted on activation; carries the pill for id-based dispatch.""" 

113 

114 def __init__(self, pill: FleetPill) -> None: 

115 super().__init__() 

116 self.pill = pill 

117 

118 BINDINGS: ClassVar[list[BindingType]] = [ 

119 Binding("enter", "press", "Press", show=False), 

120 Binding("space", "press", "Press", show=False), 

121 ] 

122 

123 def press(self) -> None: 

124 """Activate this pill (shared by the mouse and keyboard paths).""" 

125 self.post_message(self.Pressed(self)) 

126 

127 def action_press(self) -> None: 

128 self.press() 

129 

130 def on_click(self, event: events.Click) -> None: 

131 event.stop() 

132 self.press() 

133 

134 

135@dataclass 

136class _RoleEdit: 

137 """Mutable editor state for one role. 

138 

139 ``tensor_split`` carries a manual split from the loaded view so re-applying an 

140 unedited placement preserves it (an even split would OOM the smaller of two 

141 unequal cards). It is cleared the moment the user toggles this role's devices, 

142 since a split sized for the old card set no longer applies to the new one. 

143 """ 

144 

145 role: WorkerRole 

146 model: str 

147 devices: set[int] 

148 replicas: int 

149 tensor_split: tuple[int, ...] | None = None 

150 

151 

152class FleetBody(Widget): 

153 """Live fleet GPU table and interactive placement editor.""" 

154 

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

156 

157 applying: reactive[bool] = reactive(False) 

158 

159 class PlacementReloading(Message): 

160 """Posted while an apply/clear reloads the fleet, so the chat screen can 

161 hold submissions until the reload finishes instead of hitting a 429.""" 

162 

163 def __init__(self, active: bool) -> None: 

164 self.active = active 

165 super().__init__() 

166 

167 def __init__(self) -> None: 

168 super().__init__(id="fleet-body") 

169 self._edits: dict[WorkerRole, _RoleEdit] = {} 

170 self._device_indices: tuple[int, ...] = () 

171 self._view_manual = False 

172 self._page = 0 

173 self._command_actions: dict[str, Callable[[], None]] = { 

174 _CMD_PREVIEW: self.action_preview, 

175 _CMD_APPLY: self.action_apply, 

176 _CMD_AUTO: self.action_clear, 

177 } 

178 

179 def watch_applying(self, applying: bool) -> None: 

180 """Disable the editor controls while an apply/clear is in flight, surface a 

181 'Rebuilding fleet…' status so the reload isn't a silent idle screen, and tell 

182 the chat screen to hold submissions until the fleet finishes reloading.""" 

183 self.post_message(self.PlacementReloading(applying)) 

184 with contextlib.suppress(NoMatches): 

185 self.query_one(_EDITOR_ID, Vertical).disabled = applying 

186 if applying: 

187 self._show_rebuilding() 

188 

189 def _show_rebuilding(self) -> None: 

190 """Hold a 'Rebuilding fleet…' status until the reloaded fleet reports ready. 

191 

192 The reload restarts a role and warms its model off-thread (tens of seconds 

193 for a tensor-split giant); without a positive status the editor is just a 

194 greyed, silent screen and the user cannot tell the apply took, or whether a 

195 stale draft still stands. The re-render that ``_change_placement`` runs once 

196 the fleet is ready (or the change failed) clears this back to the live state. 

197 """ 

198 with contextlib.suppress(NoMatches): 

199 state = self.query_one(_STATE_ID, Static) 

200 state.update(msg.FLEET_STATE_REBUILDING) 

201 state.set_class(False, "-edited") 

202 state.set_class(False, "-manual") 

203 state.set_class(True, "-rebuilding") 

204 

205 def compose(self) -> ComposeResult: 

206 with Vertical(id="placement-layout"): 

207 with Horizontal(id="placement-titlebar"): 

208 yield Static(msg.FLEET_TITLE, id="placement-title") 

209 yield Static("", id="placement-state") 

210 help_icon = Static(msg.FLEET_HELP_ICON, id="placement-help") 

211 help_icon.tooltip = msg.FLEET_HELP_TOOLTIP 

212 yield help_icon 

213 yield GpuFleetPanel() 

214 yield Static("", id="placement-skipped") 

215 yield Vertical(id="placement-editor") 

216 with Horizontal(id="placement-commands"): 

217 yield FleetPill(msg.FLEET_CMD_PREVIEW, id=_CMD_PREVIEW, classes="cmd-pill") 

218 yield FleetPill(msg.FLEET_CMD_APPLY, id=_CMD_APPLY, classes="cmd-pill") 

219 yield FleetPill(msg.FLEET_CMD_AUTO, id=_CMD_AUTO, classes="cmd-pill") 

220 yield Static(_HINT, id="placement-hint") 

221 

222 def on_mount(self) -> None: 

223 self._load_worker() 

224 

225 @work(thread=True, exit_on_error=False) 

226 def _load_worker(self) -> None: 

227 """Fetch placement off the UI thread and populate the widget. 

228 

229 get_placement resolves the plan, which can spawn a device-probe 

230 subprocess on a cold cache -- too slow for the event loop. 

231 """ 

232 try: 

233 view = get_placement() 

234 except Exception as exc: 

235 log.debug("Failed to load placement", exc_info=True) 

236 call_from_thread(self, self._render_load_failure, str(exc)) 

237 return 

238 call_from_thread(self, self._render_view, view) 

239 

240 def _render_load_failure(self, reason: str) -> None: 

241 """Name the placement-load failure in the panel instead of probing forever.""" 

242 self.query_one(_FLEET_PANEL_ID, GpuFleetPanel).set_probe_failed(reason) 

243 self.notify(reason, severity="error") 

244 

245 # -- rendering ------------------------------------------------------- 

246 

247 def _render_view(self, view: PlacementView) -> None: 

248 """Reset the widget (title, live table, editor) from a resolved placement view.""" 

249 self._view_manual = view.manual 

250 self._device_indices = tuple(g.index for g in view.gpus) 

251 edits = { 

252 r.role: _RoleEdit(r.role, r.model, set(r.devices), r.replicas, r.tensor_split) 

253 for r in view.roles 

254 } 

255 # Rows render in _EDITOR_ROLE_ORDER; any role beyond it keeps plan order. 

256 self._edits = {role: edits.pop(role) for role in _EDITOR_ROLE_ORDER if role in edits} 

257 self._edits.update(edits) 

258 self._page = 0 

259 self._build_editor() 

260 self._refresh_title(dirty=False) 

261 self._update_fleet_panel(view) 

262 self._render_skipped(view) 

263 if view.co_tenants: 

264 names = ", ".join(role.value for role in view.co_tenants) 

265 self.notify(f"Sharing memory, one loaded at a time: {names}", severity="information") 

266 if view.unplaceable: 

267 names = ", ".join(role.value for role in view.unplaceable) 

268 self.notify(f"Does not fit: {names}", severity="warning") 

269 if view.rejected_spec_json: 

270 self.notify(msg.FLEET_SAVED_PLACEMENT_IGNORED, severity="warning") 

271 

272 def _render_skipped(self, view: PlacementView) -> None: 

273 """Show a 'not downloaded' line per role skipped for a missing model.""" 

274 widget = self.query_one(_SKIPPED_ID, Static) 

275 widget.display = bool(view.skipped_not_installed) 

276 widget.update( 

277 "\n".join( 

278 msg.FLEET_MODEL_NOT_DOWNLOADED.format( 

279 role=skipped.role.value, model=_clean_model_name(skipped.model) 

280 ) 

281 for skipped in view.skipped_not_installed 

282 ) 

283 ) 

284 

285 def _page_devices(self) -> tuple[int, ...]: 

286 """The GPU indices visible on the current page.""" 

287 start = self._page * _PLACEMENT_PAGE_SIZE 

288 return self._device_indices[start : start + _PLACEMENT_PAGE_SIZE] 

289 

290 def _page_count(self) -> int: 

291 """Number of GPU pages at the current fleet size.""" 

292 n = len(self._device_indices) 

293 return max(1, (n + _PLACEMENT_PAGE_SIZE - 1) // _PLACEMENT_PAGE_SIZE) 

294 

295 def _build_editor(self) -> None: 

296 """Rebuild the placement grid: GPU header, one row per role, optional pager. 

297 

298 A single-GPU fleet has nothing to arrange -- every role can only live on 

299 that card -- so the grid, command pills, and hint collapse to a one-line 

300 note and the drawer stays a pure live monitor. 

301 """ 

302 container = self.query_one(_EDITOR_ID, Vertical) 

303 container.remove_children() 

304 single = len(self._device_indices) <= 1 

305 for selector in (_COMMANDS_ID, _HINT_WIDGET_ID): 

306 self.query_one(selector).display = not single 

307 if single: 

308 container.mount(Label(msg.FLEET_SINGLE_GPU_NOTE, classes="single-gpu-note")) 

309 return 

310 devices = self._page_devices() 

311 widgets: list[Horizontal] = [self._gpu_header_row(devices)] 

312 for role, edit in self._edits.items(): 

313 kind = _role_kind(role) 

314 children: list[FleetPill | Label] = [Label(f"{role.value:<7}", classes="role-name")] 

315 for idx in devices: 

316 on = " on" if idx in edit.devices else "" 

317 children.append( 

318 FleetPill( 

319 f" {idx} ", id=f"dev-{role.value}-{idx}", classes=f"dev-toggle {kind}{on}" 

320 ) 

321 ) 

322 if role in _REPLICA_ROLES: 

323 children.append(FleetPill(" - ", id=f"rep-{role.value}-dec", classes="rep-pill")) 

324 children.append( 

325 Label(f"x{edit.replicas}", id=f"repn-{role.value}", classes="rep-count") 

326 ) 

327 children.append(FleetPill(" + ", id=f"rep-{role.value}-inc", classes="rep-pill")) 

328 elif role in _SINGLE_ROLES: 

329 children.append(Label(msg.FLEET_TAG_SINGLE, classes="role-tag")) 

330 elif len(edit.devices) > 1: 

331 children.append(Label(msg.FLEET_TAG_SPLIT, classes="role-tag")) 

332 widgets.append(Horizontal(*children, classes="role-row")) 

333 container.mount(*widgets) 

334 if self._page_count() > 1: 

335 container.mount(self._pager_row()) 

336 

337 def _gpu_header_row(self, devices: tuple[int, ...]) -> Horizontal: 

338 """A header labelling the visible GPU columns.""" 

339 cells: list[Label] = [Label("GPU", classes="role-name gpu-hdr-lead")] 

340 cells += [Label(str(idx), classes="gpu-hdr") for idx in devices] 

341 return Horizontal(*cells, classes="gpu-header-row") 

342 

343 def _pager_row(self) -> Horizontal: 

344 """Prev/next controls and a page indicator for fleets past one page.""" 

345 first = self._page * _PLACEMENT_PAGE_SIZE 

346 last = first + len(self._page_devices()) - 1 

347 info = f"GPUs {first}-{last} · page {self._page + 1}/{self._page_count()}" 

348 return Horizontal( 

349 FleetPill(" ◄ ", id="pg-prev", classes="pg-pill"), 

350 Label(info, classes="pg-info"), 

351 FleetPill(" ► ", id="pg-next", classes="pg-pill"), 

352 classes="pager-row", 

353 ) 

354 

355 def _refresh_title(self, *, dirty: bool) -> None: 

356 """Reflect the placement mode in the state segment beside the title pill.""" 

357 state = self.query_one(_STATE_ID, Static) 

358 if dirty: 

359 state.update(msg.FLEET_STATE_EDITED) 

360 else: 

361 state.update(msg.FLEET_STATE_MANUAL if self._view_manual else msg.FLEET_STATE_AUTO) 

362 state.set_class(dirty, "-edited") 

363 state.set_class(not dirty and self._view_manual, "-manual") 

364 state.set_class(False, "-rebuilding") 

365 

366 def _update_fleet_panel(self, view: PlacementView) -> None: 

367 """Push the current device list and roles into the fleet panel.""" 

368 try: 

369 panel = self.query_one(_FLEET_PANEL_ID, GpuFleetPanel) 

370 except NoMatches: 

371 return 

372 labels = {g.index: g.label for g in view.gpus} 

373 roles: dict[int, str] = {} 

374 for r in view.roles: 

375 short_model = _clean_model_name(r.model) if r.model else "" 

376 badge = f"{r.role.value} - {short_model}" if short_model else r.role.value 

377 for idx in r.devices: 

378 roles[idx] = badge 

379 panel.set_devices(view.gpus, labels=labels, roles=roles) 

380 

381 # -- editor state ---------------------------------------------------- 

382 

383 def _spec_from_editor(self) -> PlacementSpec | None: 

384 """Build a PlacementSpec from the editor; None when nothing is configured.""" 

385 if not self._edits: 

386 return None 

387 roles: dict[WorkerRole, RolePlacement] = {} 

388 for edit in self._edits.values(): 

389 if not edit.devices: 

390 raise PlacementError(f"{edit.role.value} needs at least one GPU") 

391 devices = tuple(sorted(edit.devices)) 

392 # Keep a loaded manual split only while it still matches the device set; 

393 # a stale-length split would be rejected by the spec validator. 

394 split = ( 

395 edit.tensor_split 

396 if edit.tensor_split and len(edit.tensor_split) == len(devices) 

397 else None 

398 ) 

399 roles[edit.role] = RolePlacement( 

400 devices=devices, replicas=edit.replicas, tensor_split=split 

401 ) 

402 return PlacementSpec(roles=roles) 

403 

404 def on_fleet_pill_pressed(self, event: FleetPill.Pressed) -> None: 

405 """Handle a GPU toggle, a replica -/+ press, or a command pill.""" 

406 bid = event.pill.id or "" 

407 command = self._command_actions.get(bid) 

408 if command is not None: 

409 command() 

410 return 

411 if bid in ("pg-prev", "pg-next"): 

412 step = -1 if bid == "pg-prev" else 1 

413 self._page = min(max(0, self._page + step), self._page_count() - 1) 

414 self._build_editor() 

415 return 

416 if bid.startswith("dev-"): 

417 role_value, idx_str = bid.removeprefix("dev-").rsplit("-", 1) 

418 role = WorkerRole(role_value) 

419 edit = self._edits[role] 

420 idx = int(idx_str) 

421 # Editing the device set invalidates any loaded manual split (its 

422 # length was sized for the old cards); fall back to a capacity split. 

423 edit.tensor_split = None 

424 if role in _SINGLE_ROLES: 

425 # Single pinned instance: the picked card becomes the only one. 

426 edit.devices = {idx} 

427 for other in self._page_devices(): 

428 self.query_one(f"#dev-{role.value}-{other}", FleetPill).set_class( 

429 other == idx, "on" 

430 ) 

431 elif idx in edit.devices: 

432 if len(edit.devices) > 1: # keep at least one GPU per role 

433 edit.devices.discard(idx) 

434 event.pill.remove_class("on") 

435 else: 

436 edit.devices.add(idx) 

437 event.pill.add_class("on") 

438 elif bid.startswith("rep-"): 

439 role_value, op = bid.removeprefix("rep-").rsplit("-", 1) 

440 role = WorkerRole(role_value) 

441 edit = self._edits[role] 

442 edit.replicas = max(1, edit.replicas + (1 if op == "inc" else -1)) 

443 self.query_one(f"#repn-{role.value}", Label).update(f"x{edit.replicas}") 

444 else: 

445 return 

446 self._refresh_title(dirty=True) 

447 

448 # -- actions --------------------------------------------------------- 

449 

450 def action_preview(self) -> None: 

451 """Resolve the edited placement against the hardware without applying it.""" 

452 try: 

453 spec = self._spec_from_editor() 

454 except PlacementError as exc: 

455 self.notify(str(exc), severity="error") 

456 return 

457 self._preview_worker(spec) 

458 

459 @work(thread=True, exit_on_error=False) 

460 def _preview_worker(self, spec: PlacementSpec | None) -> None: 

461 try: 

462 view = preview_placement(spec) 

463 call_from_thread(self, self._render_view, view) 

464 except Exception as exc: 

465 call_from_thread(self, self.notify, str(exc), severity="error") 

466 

467 def action_apply(self) -> None: 

468 """Apply the edited placement (persists and reloads the fleet).""" 

469 if self.applying: 

470 return 

471 try: 

472 spec = self._spec_from_editor() 

473 except PlacementError as exc: 

474 self.notify(str(exc), severity="error") 

475 return 

476 self.applying = True 

477 self._apply_worker(spec) 

478 

479 @work(thread=True, exit_on_error=False) 

480 def _apply_worker(self, spec: PlacementSpec | None) -> None: 

481 self._change_placement(lambda: set_placement(spec)) 

482 

483 def action_clear(self) -> None: 

484 """Restore automatic placement.""" 

485 if self.applying: 

486 return 

487 self.applying = True 

488 self._clear_worker() 

489 

490 @work(thread=True, exit_on_error=False) 

491 def _clear_worker(self) -> None: 

492 self._change_placement(lambda: set_placement(None)) 

493 

494 def _change_placement(self, change: Callable[[], PlacementView]) -> None: 

495 """Apply a placement change off the UI thread, then re-render the live view. 

496 

497 ``applying`` (and with it the chat screen's submit hold) stays up until the 

498 restarted chat role actually serves: releasing when the reload returns 

499 still leaves the model warming, and a prompt sent then errors with the 

500 busy 429 instead of an answer. The view is re-rendered from the live 

501 placement whether the change applied or failed, so a rejected or failed 

502 draft returns to the current read-only state instead of stranding the 

503 stale 'edited'/won't-fit editor. 

504 """ 

505 error: str | None = None 

506 try: 

507 change() 

508 wait_chat_ready() 

509 except Exception as exc: 

510 error = str(exc) 

511 try: 

512 view = get_placement() 

513 call_from_thread(self, self._render_view, view) 

514 except Exception as exc: 

515 error = error or str(exc) 

516 if error is not None: 

517 call_from_thread(self, self.notify, error, severity="error") 

518 call_from_thread(self, setattr, self, "applying", False)