From 38fcdb7b0525b9aa8b09d1530d051b8867bcd3f8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 25 Oct 2026 02:45:10 -0400 Subject: [PATCH 1/3] Restore the activity bar above the session details From ab968847a78e94bcdb4754e2210f95f5cd6ff77e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 25 Oct 2026 02:46:32 -0400 Subject: [PATCH 2/3] Restore the activity bar above the session details 910e2f0d4 (#1227) deleted both activity bars when only the lower one, under the session details, was meant to go. The upper bar comes back as it was: AboveThrobber reading Conversation.working (history reads, the turn owner, the observed thread), with the busy count that history reads feed. The comms chat view shows that one bar in the slot its old bar had. BelowThrobber and TopInk stay deleted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ejYrMLnLWah8wCDDcb57V --- src/toad/toad.tcss | 5 ++ src/toad/widgets/comms_chat.py | 10 +++- src/toad/widgets/conversation.py | 23 ++++++++ src/toad/widgets/throbber.py | 94 ++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/toad/widgets/throbber.py 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..5e5e4a52b 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, @@ -235,7 +236,14 @@ async def _refresh_source(self) -> None: history = self.message_history announced = self.app.coordination_access.revision newly_announced = announced != self._refreshed_from - changed = await history.read_changes() + undrawn = history.undrawn + if undrawn: + self.on_work_started() + try: + changed = await history.read_changes() + finally: + if undrawn: + self.on_work_finished() # Source observation and its pending first page belong to the # history, even before this tab is drawable. Composer work follows. source = history.page_source diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index f77a1f3a4..218d33af2 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 @@ -495,6 +496,8 @@ def _turn_changed(self, owner: TurnOwner) -> None: """Publish the concrete conversation's changed turn projection.""" raise NotImplementedError + busy_count = var(0) + visible_categories: var[frozenset[type[MessageCategory]]] = var( lambda: all_categories(), init=False ) @@ -541,6 +544,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, @@ -774,6 +778,7 @@ def watch_agent_binding(self, binding: AgentConnection) -> None: if self.is_attached: self.refresh_thread_status() self.turns.bound() + self.busy_count = 0 binding.update_view(self) if self.is_mounted: @@ -904,7 +909,14 @@ async def sync_project_path(self, path: Path) -> None: await self._shell.change_directory(str(path)) await self.agent_binding.project_changed(path) + def working(self) -> bool: + return self.busy_count > 0 or self.turns.owner.busy or self._observed_working + + 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 +926,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 +970,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, @@ -1114,6 +1128,12 @@ async def on_agent_fail(self, event: AgentFail) -> None: event, self ) + def on_work_started(self) -> None: + self.busy_count += 1 + + def on_work_finished(self) -> None: + self.busy_count -= 1 + @work @handles(input_events.ChangeMode) async def on_change_mode(self, event: input_events.ChangeMode) -> None: @@ -1321,6 +1341,9 @@ def on_current_working_directory_changed(self, event: CurrentWorkingDirectoryCha if self._shell is None or self._shell.pending_directory is None: self.working_directory = str(Path(event.path).resolve().absolute()) + async def watch_busy_count(self, _busy: int) -> None: + self._sync_working() + @on(TerminalTool.Projection) async def on_terminal_projection(self, message: TerminalTool.Projection) -> None: message.stop() 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 From 97c4c45976e30609989dba9a701b5842023e59ac Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 25 Oct 2026 02:49:28 -0400 Subject: [PATCH 3/3] Read the history's first page from WireHistory, not a busy counter busy_count was a second copy of whether WireHistory is reading its first page: the rebind zeroed it mid-read, the finally took it to -1, and the bar missed the next read. WireHistory now owns `reading`, set around read_changes while nothing is drawn. Conversation.history_loading() answers False; CommsChatView reads WireHistory.reading. working() reads the turn owner, the observed thread and history_loading(). Deleted: busy_count, on_work_started, on_work_finished, watch_busy_count and the zeroing on rebind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ejYrMLnLWah8wCDDcb57V --- src/toad/widgets/comms_chat.py | 12 ++++-------- src/toad/widgets/conversation.py | 24 ++++++++---------------- src/toad/widgets/wire_history.py | 19 +++++++++++++++---- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 5e5e4a52b..8447a3e6c 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -228,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 @@ -236,14 +239,7 @@ async def _refresh_source(self) -> None: history = self.message_history announced = self.app.coordination_access.revision newly_announced = announced != self._refreshed_from - undrawn = history.undrawn - if undrawn: - self.on_work_started() - try: - changed = await history.read_changes() - finally: - if undrawn: - self.on_work_finished() + changed = await history.read_changes() # Source observation and its pending first page belong to the # history, even before this tab is drawable. Composer work follows. source = history.page_source diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 218d33af2..6b69c51c6 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -496,8 +496,6 @@ def _turn_changed(self, owner: TurnOwner) -> None: """Publish the concrete conversation's changed turn projection.""" raise NotImplementedError - busy_count = var(0) - visible_categories: var[frozenset[type[MessageCategory]]] = var( lambda: all_categories(), init=False ) @@ -778,7 +776,6 @@ def watch_agent_binding(self, binding: AgentConnection) -> None: if self.is_attached: self.refresh_thread_status() self.turns.bound() - self.busy_count = 0 binding.update_view(self) if self.is_mounted: @@ -909,14 +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.busy_count > 0 or self.turns.owner.busy or self._observed_working + return self.turns.owner.busy or self._observed_working or self.history_loading() - def _sync_working(self) -> None: + def sync_working(self) -> None: self.throbber_above.sync() def _turn_changed(self, owner: TurnOwner) -> None: - self._sync_working() + 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: @@ -926,7 +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() + self.sync_working() if (prompt := self.query_one_optional(Prompt)) is not None: prompt.sync_session() @@ -1128,12 +1129,6 @@ async def on_agent_fail(self, event: AgentFail) -> None: event, self ) - def on_work_started(self) -> None: - self.busy_count += 1 - - def on_work_finished(self) -> None: - self.busy_count -= 1 - @work @handles(input_events.ChangeMode) async def on_change_mode(self, event: input_events.ChangeMode) -> None: @@ -1341,9 +1336,6 @@ def on_current_working_directory_changed(self, event: CurrentWorkingDirectoryCha if self._shell is None or self._shell.pending_directory is None: self.working_directory = str(Path(event.path).resolve().absolute()) - async def watch_busy_count(self, _busy: int) -> None: - self._sync_working() - @on(TerminalTool.Projection) async def on_terminal_projection(self, message: TerminalTool.Projection) -> None: message.stop() 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