Skip to content
This repository was archived by the owner on Apr 22, 2026. It is now read-only.

Commit 1891a43

Browse files
committed
tui: background image builds with live log viewer
1 parent a344167 commit 1891a43

3 files changed

Lines changed: 228 additions & 88 deletions

File tree

fuzzforge-cli/src/fuzzforge_cli/tui/app.py

Lines changed: 133 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
from typing import TYPE_CHECKING, Any
1414

1515
from rich.text import Text
16+
from textual import events, work
1617
from textual.app import App, ComposeResult
1718
from textual.binding import Binding
1819
from textual.containers import Horizontal, Vertical, VerticalScroll
20+
from textual.message import Message
1921
from textual.widgets import Button, DataTable, Footer, Header
2022

2123
from fuzzforge_cli.tui.helpers import (
@@ -33,6 +35,36 @@
3335
_AgentRow = tuple[str, "AIAgent", Path, str, bool]
3436

3537

38+
class SingleClickDataTable(DataTable):
39+
"""DataTable subclass that also fires ``RowClicked`` on a single mouse click.
40+
41+
Textual's built-in ``RowSelected`` only fires on Enter or on a second click
42+
of an already-highlighted row. ``RowClicked`` fires on every first click,
43+
enabling single-click-to-act UX without requiring Enter.
44+
"""
45+
46+
class RowClicked(Message):
47+
"""Fired on every single mouse click on a data row."""
48+
49+
def __init__(self, data_table: "SingleClickDataTable", cursor_row: int) -> None:
50+
self.data_table = data_table
51+
self.cursor_row = cursor_row
52+
super().__init__()
53+
54+
@property
55+
def control(self) -> "SingleClickDataTable":
56+
return self.data_table
57+
58+
async def _on_click(self, event: events.Click) -> None: # type: ignore[override]
59+
"""Forward to parent, then post RowClicked for single-click detection."""
60+
await super()._on_click(event)
61+
meta = event.style.meta
62+
if "row" in meta and self.cursor_type == "row":
63+
row_index: int = meta["row"]
64+
if row_index >= 0: # skip header row
65+
self.post_message(SingleClickDataTable.RowClicked(self, row_index))
66+
67+
3668
class FuzzForgeApp(App[None]):
3769
"""FuzzForge AI terminal user interface."""
3870

@@ -97,7 +129,7 @@ class FuzzForgeApp(App[None]):
97129
/* Modal screens */
98130
AgentSetupScreen, AgentUnlinkScreen,
99131
HubManagerScreen, LinkHubScreen, CloneHubScreen,
100-
BuildImageScreen {
132+
BuildImageScreen, BuildLogScreen {
101133
align: center middle;
102134
}
103135
@@ -132,15 +164,20 @@ class FuzzForgeApp(App[None]):
132164
}
133165
134166
#build-dialog {
135-
width: 100;
136-
height: 80%;
167+
width: 72;
168+
height: auto;
169+
max-height: 80%;
137170
border: thick #4699fc;
138171
background: $surface;
139172
padding: 2 3;
140173
}
141174
175+
#confirm-text {
176+
margin: 1 0 2 0;
177+
}
178+
142179
#build-log {
143-
height: 1fr;
180+
height: 30;
144181
border: round $panel;
145182
margin: 1 0;
146183
}
@@ -201,7 +238,7 @@ def compose(self) -> ComposeResult:
201238
yield Header()
202239
with VerticalScroll(id="main"):
203240
with Vertical(id="hub-panel", classes="panel"):
204-
yield DataTable(id="hub-table")
241+
yield SingleClickDataTable(id="hub-table")
205242
with Horizontal(id="hub-title-bar"):
206243
yield Button(
207244
"Hub Manager (h)",
@@ -220,9 +257,12 @@ def compose(self) -> ComposeResult:
220257
def on_mount(self) -> None:
221258
"""Populate tables on startup."""
222259
self._agent_rows: list[_AgentRow] = []
223-
# hub row data: (server_name, image, hub_name) | None for group headers
224-
self._hub_rows: list[tuple[str, str, str] | None] = []
225-
self.query_one("#hub-panel").border_title = "Hub Servers [dim](Enter to build)[/dim]"
260+
self._hub_rows: list[tuple[str, str, str, bool] | None] = []
261+
# Background build tracking
262+
self._active_builds: dict[str, object] = {} # image -> Popen
263+
self._build_logs: dict[str, list[str]] = {} # image -> log lines
264+
self._build_results: dict[str, bool] = {} # image -> success
265+
self.query_one("#hub-panel").border_title = "Hub Servers [dim](click ✗ Not built to build)[/dim]"
226266
self.query_one("#agents-panel").border_title = "AI Agents"
227267
self._refresh_agents()
228268
self._refresh_hub()
@@ -249,9 +289,10 @@ def _refresh_agents(self) -> None:
249289
def _refresh_hub(self) -> None:
250290
"""Refresh the hub servers table, grouped by source hub."""
251291
self._hub_rows = []
252-
table = self.query_one("#hub-table", DataTable)
292+
table = self.query_one("#hub-table", SingleClickDataTable)
253293
table.clear(columns=True)
254294
table.add_columns("Server", "Image", "Hub", "Status")
295+
table.cursor_type = "row"
255296

256297
try:
257298
fuzzforge_root = find_fuzzforge_root()
@@ -312,7 +353,9 @@ def _refresh_hub(self) -> None:
312353
image = server.get("image", "unknown")
313354
enabled = server.get("enabled", True)
314355

315-
if not enabled:
356+
if image in getattr(self, "_active_builds", {}):
357+
status_cell = Text("⏳ Building…", style="yellow")
358+
elif not enabled:
316359
status_cell = Text("Disabled", style="dim")
317360
elif is_ready:
318361
status_cell = Text("✓ Ready", style="green")
@@ -325,15 +368,22 @@ def _refresh_hub(self) -> None:
325368
hub_name,
326369
status_cell,
327370
)
328-
self._hub_rows.append((name, image, hub_name))
371+
self._hub_rows.append((name, image, hub_name, is_ready))
329372

330373
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
331-
"""Handle row selection on agents and hub tables."""
374+
"""Handle Enter-key row selection on the agents table."""
332375
if event.data_table.id == "agents-table":
333376
self._handle_agent_row(event.cursor_row)
334377
elif event.data_table.id == "hub-table":
335378
self._handle_hub_row(event.cursor_row)
336379

380+
def on_single_click_data_table_row_clicked(
381+
self, event: SingleClickDataTable.RowClicked
382+
) -> None:
383+
"""Handle single mouse-click on a hub table row."""
384+
if event.data_table.id == "hub-table":
385+
self._handle_hub_row(event.cursor_row)
386+
337387
def _handle_agent_row(self, idx: int) -> None:
338388
"""Open agent setup/unlink for the selected agent row."""
339389
if idx < 0 or idx >= len(self._agent_rows):
@@ -357,14 +407,25 @@ def _handle_agent_row(self, idx: int) -> None:
357407
)
358408

359409
def _handle_hub_row(self, idx: int) -> None:
360-
"""Open the build dialog for the selected hub tool row."""
410+
"""Handle a click on a hub table row."""
361411
if idx < 0 or idx >= len(self._hub_rows):
362412
return
363413
row_data = self._hub_rows[idx]
364414
if row_data is None:
365415
return # group header row — ignore
366416

367-
server_name, image, hub_name = row_data
417+
server_name, image, hub_name, is_ready = row_data
418+
419+
# If a build is already running, open the live log viewer
420+
if image in self._active_builds:
421+
from fuzzforge_cli.tui.screens.build_log import BuildLogScreen
422+
self.push_screen(BuildLogScreen(image))
423+
return
424+
425+
if is_ready:
426+
self.notify(f"{image} is already built ✓", severity="information")
427+
return
428+
368429
if hub_name == "manual":
369430
self.notify("Manual servers must be built outside FuzzForge")
370431
return
@@ -373,14 +434,68 @@ def _handle_hub_row(self, idx: int) -> None:
373434

374435
self.push_screen(
375436
BuildImageScreen(server_name, image, hub_name),
376-
callback=self._on_image_built,
437+
callback=lambda confirmed, sn=server_name, im=image, hn=hub_name:
438+
self._on_build_confirmed(confirmed, sn, im, hn),
377439
)
378440

379-
def _on_image_built(self, success: bool) -> None:
380-
"""Refresh hub status after a build attempt."""
441+
def _on_build_confirmed(self, confirmed: bool, server_name: str, image: str, hub_name: str) -> None:
442+
"""Start a background build if the user confirmed."""
443+
if not confirmed:
444+
return
445+
self._build_logs[image] = []
446+
self._build_results.pop(image, None)
447+
self._active_builds[image] = True # mark as pending so ⏳ shows immediately
448+
self._refresh_hub() # show ⏳ Building… immediately
449+
self._run_build(server_name, image, hub_name)
450+
451+
@work(thread=True)
452+
def _run_build(self, server_name: str, image: str, hub_name: str) -> None:
453+
"""Build a Docker/Podman image in a background thread."""
454+
import subprocess
455+
from fuzzforge_cli.tui.helpers import build_image, find_dockerfile_for_server
456+
457+
logs = self._build_logs.setdefault(image, [])
458+
459+
dockerfile = find_dockerfile_for_server(server_name, hub_name)
460+
if dockerfile is None:
461+
logs.append(f"ERROR: Dockerfile not found for '{server_name}' in hub '{hub_name}'")
462+
self._build_results[image] = False
463+
self._active_builds.pop(image, None)
464+
self.call_from_thread(self._on_build_done, image, False)
465+
return
466+
467+
logs.append(f"Building {image} from {dockerfile.parent}")
468+
logs.append("")
469+
470+
try:
471+
proc = build_image(image, dockerfile)
472+
except FileNotFoundError as exc:
473+
logs.append(f"ERROR: {exc}")
474+
self._build_results[image] = False
475+
self._active_builds.pop(image, None)
476+
self.call_from_thread(self._on_build_done, image, False)
477+
return
478+
479+
self._active_builds[image] = proc # replace pending marker with actual process
480+
self.call_from_thread(self._refresh_hub) # show ⏳ in table
481+
482+
assert proc.stdout is not None
483+
for line in proc.stdout:
484+
logs.append(line.rstrip())
485+
486+
proc.wait()
487+
self._active_builds.pop(image, None)
488+
success = proc.returncode == 0
489+
self._build_results[image] = success
490+
self.call_from_thread(self._on_build_done, image, success)
491+
492+
def _on_build_done(self, image: str, success: bool) -> None:
493+
"""Called on the main thread when a background build finishes."""
381494
self._refresh_hub()
382495
if success:
383-
self.notify("Image built successfully", severity="information")
496+
self.notify(f"✓ {image} built successfully", severity="information")
497+
else:
498+
self.notify(f"✗ {image} build failed — click row for log", severity="error")
384499

385500
def on_button_pressed(self, event: Button.Pressed) -> None:
386501
"""Handle button presses."""
Lines changed: 24 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
1-
"""Build-image modal screen for FuzzForge TUI.
1+
"""Build-image confirm dialog for FuzzForge TUI.
22
3-
Provides a modal dialog that runs ``docker/podman build`` for a single
4-
hub tool and streams the build log into a scrollable log area.
3+
Simple modal that asks the user to confirm before starting a background
4+
build. The actual build is managed by the app so the user is never
5+
locked on this screen.
56
67
"""
78

89
from __future__ import annotations
910

10-
from pathlib import Path
11-
12-
from textual import work
1311
from textual.app import ComposeResult
1412
from textual.containers import Horizontal, Vertical
1513
from textual.screen import ModalScreen
16-
from textual.widgets import Button, Label, Log
17-
18-
from fuzzforge_cli.tui.helpers import build_image, find_dockerfile_for_server
14+
from textual.widgets import Button, Label
1915

2016

2117
class BuildImageScreen(ModalScreen[bool]):
22-
"""Modal that builds a Docker/Podman image and streams the build log."""
18+
"""Quick confirmation before starting a background Docker/Podman build."""
2319

24-
BINDINGS = [("escape", "cancel", "Close")]
20+
BINDINGS = [("escape", "cancel", "Cancel")]
2521

2622
def __init__(self, server_name: str, image: str, hub_name: str) -> None:
2723
super().__init__()
@@ -30,74 +26,32 @@ def __init__(self, server_name: str, image: str, hub_name: str) -> None:
3026
self._hub_name = hub_name
3127

3228
def compose(self) -> ComposeResult:
33-
"""Compose the build dialog layout."""
3429
with Vertical(id="build-dialog"):
3530
yield Label(f"Build {self._image}", classes="dialog-title")
3631
yield Label(
3732
f"Hub: {self._hub_name} • Tool: {self._server_name}",
3833
id="build-subtitle",
3934
)
40-
yield Log(id="build-log", auto_scroll=True)
41-
yield Label("", id="build-status")
35+
yield Label(
36+
"The image will be built in the background.\n"
37+
"You\'ll receive a notification when it\'s done.",
38+
id="confirm-text",
39+
)
4240
with Horizontal(classes="dialog-buttons"):
43-
yield Button("Close", variant="default", id="btn-close", disabled=True)
41+
yield Button("Build", variant="success", id="btn-build")
42+
yield Button("Cancel", variant="default", id="btn-cancel")
4443

4544
def on_mount(self) -> None:
46-
"""Start the build as soon as the screen is shown."""
47-
self._start_build()
48-
49-
def action_cancel(self) -> None:
50-
"""Only dismiss when the build is not running (Close button enabled)."""
51-
close_btn = self.query_one("#btn-close", Button)
52-
if not close_btn.disabled:
53-
self.dismiss(False)
45+
# Ensure a widget is focused so both buttons respond to a single click.
46+
# Default to Cancel so Build is never pre-selected.
47+
self.query_one("#btn-cancel", Button).focus()
5448

5549
def on_button_pressed(self, event: Button.Pressed) -> None:
56-
"""Handle Close button."""
57-
if event.button.id == "btn-close":
58-
self.dismiss(self._succeeded)
59-
60-
@work(thread=True)
61-
def _start_build(self) -> None:
62-
"""Run the build in a background thread and stream output."""
63-
self._succeeded = False
64-
log = self.query_one("#build-log", Log)
65-
status = self.query_one("#build-status", Label)
66-
67-
dockerfile = find_dockerfile_for_server(self._server_name, self._hub_name)
68-
if dockerfile is None:
69-
log.write_line(f"ERROR: Dockerfile not found for '{self._server_name}' in hub '{self._hub_name}'")
70-
status.update("[red]Build failed — Dockerfile not found[/red]")
71-
self.query_one("#btn-close", Button).disabled = False
72-
return
73-
74-
log.write_line(f"$ {self._get_engine()} build -t {self._image} {dockerfile.parent}")
75-
log.write_line("")
76-
77-
try:
78-
proc = build_image(self._image, dockerfile)
79-
except FileNotFoundError as exc:
80-
log.write_line(f"ERROR: {exc}")
81-
status.update("[red]Build failed — engine not found[/red]")
82-
self.query_one("#btn-close", Button).disabled = False
83-
return
84-
85-
assert proc.stdout is not None
86-
for line in proc.stdout:
87-
log.write_line(line.rstrip())
88-
89-
proc.wait()
90-
91-
if proc.returncode == 0:
92-
self._succeeded = True
93-
status.update(f"[green]✓ Built {self._image} successfully[/green]")
94-
else:
95-
status.update(f"[red]✗ Build failed (exit {proc.returncode})[/red]")
96-
97-
self.query_one("#btn-close", Button).disabled = False
50+
event.stop()
51+
if event.button.id == "btn-build":
52+
self.dismiss(True)
53+
elif event.button.id == "btn-cancel":
54+
self.dismiss(False)
9855

99-
@staticmethod
100-
def _get_engine() -> str:
101-
import os
102-
engine = os.environ.get("FUZZFORGE_ENGINE__TYPE", "docker").lower()
103-
return "podman" if engine == "podman" else "docker"
56+
def action_cancel(self) -> None:
57+
self.dismiss(False)

0 commit comments

Comments
 (0)