Skip to content

Commit 0b174ee

Browse files
vikast908claude
andcommitted
feat(tui): animate the run dashboard between log events
The Live held a static render() snapshot, refreshed only when a log line arrived - during one long LLM call the stage text and the elapsed clock froze for minutes and the run looked hung. The dashboard object is now the Live renderable (__rich_console__), so auto-refresh re-renders it ~8x/s: the clock ticks and active stages (drafting/critiquing/humanising) animate with a braille spinner + cycling dots. Settled stages stay static. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 65c0418 commit 0b174ee

4 files changed

Lines changed: 58 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2323
- **Interactive TUI** - escalation picker (fix/instruct/approve-as-is/go-autonomous/read on a
2424
stalled unit), manual divergent-variant picking, outline+thesis approval gate after `new`,
2525
post-run summary card + terminal bell, and a draft-opening glimpse in the dashboard.
26+
- **Animated run dashboard** - the dashboard is now a live Rich renderable
27+
(auto-refreshed ~8×/s), so the elapsed clock ticks and active stages
28+
(drafting/critiquing/humanising…) show a spinner with moving dots during long
29+
model calls instead of freezing until the next log event.
2630
- **Compact welcome screen** - the startup screen shrank from ~66 to ~33 lines so the
2731
wordmark stays visible at the first prompt; the full command list moved to `/help` and
2832
the feature board to a new **`/features`** command (one-line feature status in the

resume.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,13 @@ launched `writing-agent` from the same PowerShell window where a test command ha
189189
test env var can no longer silently can every model call.
190190
- Guard tests: welcome height budget (≤14 lines, the regression that started this),
191191
fake-warning presence, /features + /help tables render (`test_ui.py`, +3).
192+
- **Run dashboard animates (user feedback on a live run):** the `Live` previously got a
193+
static `dash.render()` snapshot, re-rendered only on log events - during one long
194+
critic call the stage text AND the elapsed clock froze for minutes. The dash object is
195+
now the renderable (`__rich_console__`), so Live's auto-refresh (8/s) re-renders it
196+
continuously: clock ticks, and active stages (``) get a braille spinner + cycling
197+
dots (`⠹ critiquing..`). Settled stages (reviewed/committed) stay static. +1 test
198+
(animation frames differ; dash is print-able). **215 tests pass.**
192199
- **Next step:** user re-runs `writing-agent` in a clean terminal (no BOOK_AGENT_FAKE) and
193200
retries the live article: chat-propose → "go ahead" → run.
194201

src/book_agent/shell.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,7 +1055,14 @@ def _execute_cmd(cmd_line: str, console, cfg, settings, state) -> None:
10551055

10561056
class _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":

tests/test_ui.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,26 @@ def test_run_dashboard_log_parsing():
5757
d.render() # builds a renderable without raising
5858

5959

60+
def test_run_dashboard_stage_animates(monkeypatch):
61+
"""Active stages (…) must visibly move between log events: Live re-renders the
62+
dash object, and the label changes frame-to-frame. Terminal stages stay static."""
63+
import time as _time
64+
65+
from book_agent.shell import _RunDashboard
66+
d = _RunDashboard("b", total=1, done=0)
67+
d.stage = "critiquing…"
68+
monkeypatch.setattr(_time, "monotonic", lambda: 0.0)
69+
first = d._stage_label()
70+
monkeypatch.setattr(_time, "monotonic", lambda: 0.5)
71+
second = d._stage_label()
72+
assert first != second # spinner/dots advanced
73+
assert "critiquing" in first
74+
d.stage = "committed"
75+
assert d._stage_label() == "committed" # no spinner on settled stages
76+
# The dash itself is a Rich renderable (handed to Live directly).
77+
_record_console().print(d)
78+
79+
6080
def _record_console():
6181
import io
6282

0 commit comments

Comments
 (0)