Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/toad/toad.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ LoadingIndicator {
background: transparent;
}

Throbber {
width: 100%;
height: 1;
}

.-hidden {
display: none;
}
Expand Down
4 changes: 4 additions & 0 deletions src/toad/widgets/comms_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/toad/widgets/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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()

Expand Down Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions src/toad/widgets/throbber.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 15 additions & 4 deletions src/toad/widgets/wire_history.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""A channel or direct conversation drawn by the line history, with its read marks."""

from __future__ import annotations

from typing import TYPE_CHECKING

from textual.reactive import var

from agent_comms.coordination_errors import StaleRevision
from agent_comms.historical_views import ConversationObservation

Expand Down Expand Up @@ -32,6 +34,8 @@
"""

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:
Expand Down Expand Up @@ -82,10 +86,17 @@
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

Expand Down
Loading