diff --git a/src/toad/toad.tcss b/src/toad/toad.tcss index 090488fcf..d4a7b3079 100644 --- a/src/toad/toad.tcss +++ b/src/toad/toad.tcss @@ -24,6 +24,11 @@ LoadingIndicator { background: transparent; } +Throbber { + width: 100%; + height: 1; +} + .-hidden { display: none; } diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 5740a0596..8447a3e6c 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -95,6 +95,7 @@ def compose(self) -> ComposeResult: yield Flash() with containers.Vertical(id="prompt-stack"): yield from self.conversation.compose_activity(self) + yield self.throbber_above yield self.conversation.prompt().data_bind( project_path=Conversation.project_path, working_directory=Conversation.working_directory, @@ -227,6 +228,9 @@ async def _refresh(self) -> Worker | None: return return history.page_source.schedule_refresh(self) + def history_loading(self) -> bool: + return self.message_history.reading + async def _refresh_source(self) -> None: """Read what changed in the conversation and draw it: the history's source work.""" from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index f77a1f3a4..6b69c51c6 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -112,6 +112,7 @@ from toad.widgets.splash_text import ThreadLoading from toad.widgets.terminal import Terminal from toad.widgets.terminal_tool import TerminalTool +from toad.widgets.throbber import AboveThrobber from toad.widgets.transcript_history import JumpToLatest, TranscriptHistory from toad.widgets.user_input import UserInput @@ -541,6 +542,7 @@ def __init__( self.turns = ConversationTurn( self._turn_changed, lambda: self.agent_binding.presentation.turns ) + self.throbber_above = AboveThrobber(self.working) self._initialize_session( project_path, agent, @@ -904,7 +906,18 @@ async def sync_project_path(self, path: Path) -> None: await self._shell.change_directory(str(path)) await self.agent_binding.project_changed(path) + def history_loading(self) -> bool: + """Whether this view's history is reading its first page; only wire histories page in.""" + return False + + def working(self) -> bool: + return self.turns.owner.busy or self._observed_working or self.history_loading() + + def sync_working(self) -> None: + self.throbber_above.sync() + def _turn_changed(self, owner: TurnOwner) -> None: + self.sync_working() if (prompt := self.query_one_optional(Prompt)) is not None: prompt.sync_turn() if (details := self.query_one_optional(SessionDetails)) is not None: @@ -914,6 +927,7 @@ def _turn_changed(self, owner: TurnOwner) -> None: @on(ObservedThreadActivity.Changed) def _observed_activity_changed(self, event: ObservedThreadActivity.Changed) -> None: event.stop() + self.sync_working() if (prompt := self.query_one_optional(Prompt)) is not None: prompt.sync_session() @@ -957,6 +971,7 @@ def compose(self) -> ComposeResult: ), ) self.refresh_thread_status() + yield self.throbber_above yield self.thread_activity prompt = self.operational_sources.prompt(self).data_bind( project_path=Conversation.project_path, diff --git a/src/toad/widgets/throbber.py b/src/toad/widgets/throbber.py new file mode 100644 index 000000000..fe2aa986f --- /dev/null +++ b/src/toad/widgets/throbber.py @@ -0,0 +1,94 @@ +from collections.abc import Callable +from functools import cached_property +from typing import ClassVar + +from rich.segment import Segment + +from textual.visual import Visual + +from textual.style import Style +from textual.strip import Strip +from textual.visual import RenderOptions +from textual.reactive import reactive +from textual.css.styles import RulesMap + +from ui_theme.effects import ACTIVITY_SWEEP, Ribbon +from ui_theme.scheme import TurnBandColour + +from toad.frame_presentation import Animated +from toad.textual_colours import BlockInk, BottomInk + + +class ThrobberVisual(Visual): + """The activity sweep: a band of theme colours moving across one row. + + `ui_theme.effects.ACTIVITY_SWEEP` says which colours and how fast; the + app's colours backend paints the frame the animation shows now. + """ + + def __init__(self, throbber: Throbber) -> None: + self.throbber = throbber + + def render_strips( + self, width: int, height: int | None, style: Style, options: RenderOptions + ) -> list[Strip]: + throbber = self.throbber + base = Strip([Segment("█" * width, style.rich_style)], cell_length=width) + frame = throbber.effect_frame(throbber.effect) + return [throbber.app.colours.paint_gradient(frame, base, edge=throbber.ink_edge)] + + def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: + return container_width + + def get_height(self, rules: RulesMap, width: int) -> int: + return 1 + + +class Throbber(Animated[Ribbon]): + _paint_only_render = True + tone = TurnBandColour + # The row is always reserved; only its paint and animation change. + busy = reactive(False, layout=False) + effect = ACTIVITY_SWEEP + ink_edge: ClassVar[type[BlockInk]] + + @cached_property + def _busy_visual(self) -> ThrobberVisual: + # Glyph geometry belongs here; frame, colours and cadence stay in the + # shared effect. A changing band never changes the reserved row. + return ThrobberVisual(self) + + def on_mount(self) -> None: + self.watch_busy(self.busy) + + def watch_busy(self, busy: bool) -> None: + # The activity owner's sync changes animation ownership and retires its paint. + if busy and self.is_mounted: + if self.effect not in self.effects: + self.run_effect(self.effect) + else: + self.stop_effect(self.effect) + self.refresh() + + def render(self) -> ThrobberVisual | str: + return self._busy_visual if self.busy else "" + + +class ObservedThrobber(Throbber): + """Paint an existing activity owner without a writable busy mirror.""" + + def __init__(self, read_busy: Callable[[], bool], **kwargs) -> None: + super().__init__(**kwargs) + self._read_busy = read_busy + + def compute_busy(self) -> bool: + return self._read_busy() + + def sync(self) -> None: + self.watch_busy(self.busy) + + +class AboveThrobber(ObservedThrobber): + """The upper bar hugs the status from its cell's bottom edge.""" + + ink_edge = BottomInk diff --git a/src/toad/widgets/wire_history.py b/src/toad/widgets/wire_history.py index 01bcd284c..b34faf17e 100644 --- a/src/toad/widgets/wire_history.py +++ b/src/toad/widgets/wire_history.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING +from textual.reactive import var + from agent_comms.coordination_errors import StaleRevision from agent_comms.historical_views import ConversationObservation @@ -32,6 +34,8 @@ class WireHistory[ViewT: CommsChatView](VisibleReadMarking, ConversationHistory[ """ page_source: WirePageBuffer | ClosedHistory + # Reading the bound source's first page: the view's activity bar shows it. + reading = var(False) def __init__(self, prepared: PreparedTranscriptPage, page_source: WirePageBuffer | ClosedHistory, *, view: ViewT, notification_view, **kwargs) -> None: @@ -82,10 +86,17 @@ def selected_categories(self): async def read_changes(self) -> bool: """Draw what changed in the source; False when the stores are as last drawn.""" source = self.page_source - observed = await source.observe_changes(source.revision) - if source is not self.page_source: - return False - return await observed.apply(self, source) + self.reading = self.undrawn + try: + observed = await source.observe_changes(source.revision) + if source is not self.page_source: + return False + return await observed.apply(self, source) + finally: + self.reading = False + + def watch_reading(self, _reading: bool) -> None: + self.view.sync_working() # What is drawn