@@ -1055,7 +1055,14 @@ def _execute_cmd(cmd_line: str, console, cfg, settings, state) -> None:
10551055
10561056class _RunDashboard :
10571057 """Live, multi-line view for `run`: header (elapsed + live tokens), a chapter
1058- progress bar, the current unit + stage, and a short scroll of recent events."""
1058+ progress bar, the current unit + stage, and a short scroll of recent events.
1059+
1060+ The dashboard object itself is handed to rich.Live (it renders via
1061+ __rich_console__), so the auto-refresh thread re-renders it ~8x/s - the
1062+ elapsed clock ticks and active stages animate even when the pipeline is deep
1063+ inside one long LLM call and no log event arrives for minutes."""
1064+
1065+ _SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" # braille spinner, same family as console.status "dots"
10591066
10601067 def __init__ (self , book_id : str , total : int , done : int , brief : str = "" ):
10611068 self .book_id = book_id
@@ -1072,6 +1079,19 @@ def _elapsed(self) -> str:
10721079 s = int (time .time () - self .start )
10731080 return f"{ s // 60 :02d} :{ s % 60 :02d} "
10741081
1082+ def _stage_label (self ) -> str :
1083+ """Active stages (ending in …) get a spinner + cycling dots so a long
1084+ model call visibly works instead of looking hung."""
1085+ if not self .stage .endswith ("…" ):
1086+ return self .stage
1087+ now = time .monotonic ()
1088+ spin = self ._SPIN [int (now * 8 ) % len (self ._SPIN )]
1089+ dots = "." * (1 + int (now * 2.5 ) % 3 )
1090+ return f"{ spin } { self .stage [:- 1 ]} { dots } "
1091+
1092+ def __rich_console__ (self , console , options ):
1093+ yield self .render ()
1094+
10751095 def render (self ):
10761096 from rich .console import Group
10771097 from rich .text import Text
@@ -1101,7 +1121,7 @@ def render(self):
11011121 stage = Text (" " )
11021122 if self .unit :
11031123 stage .append (self .unit + " " , style = PARCH )
1104- stage .append ("· " + self .stage , style = f"italic { INK } " )
1124+ stage .append ("· " + self ._stage_label () , style = f"italic { INK } " )
11051125 if self .verdict :
11061126 stage .append (" " + self .verdict , style = DIM )
11071127 rows = [head , * rows_brief , bar , stage ]
@@ -1289,11 +1309,12 @@ def run_with_dashboard(cfg, uid: str, book_id: str, console, *, force: bool = Fa
12891309 total , done_so_far = 1 , 0
12901310
12911311 dash = _RunDashboard (book_id , total , done_so_far , brief = brief )
1292- with Live (dash .render (), console = console , refresh_per_second = 8 ,
1312+ # The dash object (not a snapshot) is the renderable: Live's auto-refresh
1313+ # re-renders it 8x/s, so the clock + stage spinner animate between events.
1314+ with Live (dash , console = console , refresh_per_second = 8 ,
12931315 transient = False , vertical_overflow = "visible" ) as live :
1294- def _log (msg : str , dash = dash , live = live ) -> None : # bind: defined in a loop
1295- dash .log (msg )
1296- live .update (dash .render ())
1316+ def _log (msg : str , dash = dash ) -> None : # bind: defined in a loop
1317+ dash .log (msg ) # auto-refresh picks the mutation up within ~125ms
12971318
12981319 def _ask (prompt : str ) -> str :
12991320 # Pause the live render, take input, resume - prompting inside a Live
@@ -1307,7 +1328,6 @@ def _ask(prompt: str) -> str:
13071328
13081329 state = orchestrator .run (cfg , uid , book_id , force = force , autonomous = autonomous ,
13091330 log = _log , ask = _ask if interactive else None )
1310- live .update (dash .render ())
13111331 force , autonomous = False , None # one-shot flags; later passes resume plainly
13121332
13131333 if state .get ("phase" ) == "done" :
0 commit comments