1313from typing import TYPE_CHECKING , Any
1414
1515from rich .text import Text
16+ from textual import events , work
1617from textual .app import App , ComposeResult
1718from textual .binding import Binding
1819from textual .containers import Horizontal , Vertical , VerticalScroll
20+ from textual .message import Message
1921from textual .widgets import Button , DataTable , Footer , Header
2022
2123from fuzzforge_cli .tui .helpers import (
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+
3668class 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."""
0 commit comments