From e8591b6ec24d9c24dfde24e24dc91bbaa60ae25c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 18:22:53 -0400 Subject: [PATCH 01/23] Keep narrow message headers and receipts readable through shared rows --- src/toad/line_blocks.py | 15 +++---- src/toad/widgets/message_divider.py | 30 +++++++------ src/toad/widgets/message_notifications.py | 5 ++- src/toad/widgets/transcript_fragments.py | 8 +++- src/toad/widgets/transcript_lines.py | 51 +++++++++++++---------- 5 files changed, 64 insertions(+), 45 deletions(-) diff --git a/src/toad/line_blocks.py b/src/toad/line_blocks.py index 2a884daf9..7c97dbab0 100644 --- a/src/toad/line_blocks.py +++ b/src/toad/line_blocks.py @@ -17,7 +17,7 @@ from agent_comms.display_references import DisplayReference from agent_comms.nominal.field_codec import FieldCodec -from typing import ClassVar +from typing import ClassVar, Self from ui_theme.scheme import ( AccentColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour, SuccessColour, @@ -303,6 +303,11 @@ def text(self) -> str: def selection_text(self) -> str: return self.text + @classmethod + def titled(cls, title: str, role: type[LineRole] = MutedRole, + link: LineLink | None = None) -> Self: + return cls((Span(title, role, link),)) + @dataclass(frozen=True) class Spans(SpanText): @@ -348,14 +353,6 @@ class Summary(SpanText): def text(self) -> str: return super().text + "".join(span.text for span in self.kept) - @classmethod - def titled(cls, title: str, role: type[LineRole] = MutedRole, - link: LineLink | None = None) -> Summary: - return cls((Span(title, role, link),)) - - @property - def selection_text(self) -> str: - return self.text @dataclass(frozen=True) diff --git a/src/toad/widgets/message_divider.py b/src/toad/widgets/message_divider.py index 86b60ff52..3ca44fe07 100644 --- a/src/toad/widgets/message_divider.py +++ b/src/toad/widgets/message_divider.py @@ -33,7 +33,7 @@ class LineBlockRow(LinkedRows, Widget): """One semantic line block, drawn exactly as transcript history draws it.""" - DEFAULT_CSS = "LineBlockRow { width: 1fr; height: 1; }" + DEFAULT_CSS = "LineBlockRow { width: 1fr; height: auto; }" def __init__(self, block: LineBlock) -> None: super().__init__() @@ -48,26 +48,32 @@ def line_theme(self) -> LineTheme: def update_block(self, block: LineBlock) -> None: if block != self.block: self.block, self._lines = block, None - self.refresh() + self.refresh(layout=True) def _drawn(self, theme: LineTheme, width: int) -> PreparedLines: return PreparedLines(width, RichLineRenderer(theme, width).rows((self.block,))) - def render_line(self, y: int, paint: PaintState) -> Strip: - width = self.size.width - if y or not width: - return self.blank_row(width, paint) + def prepared_lines(self, width: int) -> PreparedLines: theme = self.line_theme if self._lines is None or self._lines[0] != theme or self._lines[1].width != width: self._lines = theme, self._drawn(theme, width) - return self.on_background(self._lines[1].strips_at(self._link_place)[0], paint, y) + return self._lines[1] + + def render_line(self, y: int, paint: PaintState) -> Strip: + width = self.size.width + if not width: + return self.blank_row(width, paint) + lines = self.prepared_lines(width) + if y >= lines.height: + return self.blank_row(width, paint) + return self.on_background(lines.strips_at(self._link_place)[y], paint, y) @height_dependency(INDEPENDENT_HEIGHT) def get_content_height(self, container: Size, viewport: Size, width: int) -> int: - return 1 + return self.prepared_lines(width).height -class SingleLineBlock(BlockContent, LineBlockRow): +class ConversationLineBlock(BlockContent, LineBlockRow): """A line block in the conversation: navigable and selectable like any block.""" def get_selection(self, selection: Selection) -> tuple[str, str] | None: @@ -94,7 +100,7 @@ def pin(self, headers: tuple[Divider, ...]) -> None: self.display = bool(headers) -class MessageDivider(Animated[Mix], SingleLineBlock): +class MessageDivider(Animated[Mix], ConversationLineBlock): """The rule above a live message: the same `Divider` block saved history draws. A message that has just arrived fades in from the accent colour. @@ -105,7 +111,7 @@ class MessageDivider(Animated[Mix], SingleLineBlock): DEFAULT_CSS = """ MessageDivider { width: 1fr; - height: 1; + height: auto; margin: 1 0 0 0; } """ @@ -139,7 +145,7 @@ def __init__(self, row: MessageRow, *, clock: MessageClock = LiveMessageClock()) self.message_row = row -class AgentActivityDivider(ConversationBlock, CommitParticipant, CategorizedBlock, SingleLineBlock): +class AgentActivityDivider(ConversationBlock, CommitParticipant, CategorizedBlock, ConversationLineBlock): """The shared activity-group row, filtered with the activity that follows it.""" ALLOW_SELECT = False diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index bcb8dc28f..344ecd9af 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -32,7 +32,10 @@ class MessageNotifications(Collapsible): MessageNotifications { height: auto; padding: 0; margin: 0; border: none; background: transparent; color: $text-muted; - CollapsibleTitle { padding: 0; min-height: 1; } + CollapsibleTitle { + padding: 0; width: 1fr; height: auto; min-height: 1; + text-wrap: wrap; text-overflow: fold; + } Contents { padding: 0; } } MessageNotifications.-working > CollapsibleTitle { color: $accent; } diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index a0e1ec5d8..c478fa007 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -39,6 +39,8 @@ LineRole, MarkdownBody, MutedRole, + SpanText, + Spans, Summary, ToggleDetails, UserRole, @@ -216,10 +218,11 @@ class Shown: """What a reader chose about one drawn fragment.""" details_open: bool = False + summary_type: ClassVar[type[SpanText]] = Summary - def summary(self, title: str, role: type[LineRole], link: LineLink) -> Summary: + def summary(self, title: str, role: type[LineRole], link: LineLink) -> SpanText: """The disclosure retains the source occurrence supplied by its fragment.""" - return Summary.titled(("▼ " if self.details_open else "▶ ") + title, role, link) + return self.summary_type.titled(("▼ " if self.details_open else "▶ ") + title, role, link) def toggled(self) -> "Shown": return replace(self, details_open=not self.details_open) @@ -229,6 +232,7 @@ def toggled(self) -> "Shown": class MessageShown(Shown): """Style and Core's feedback for one bus message, in any conversation.""" + summary_type: ClassVar[type[SpanText]] = Spans style: WireMessageStyle = field(default_factory=MarkdownMessageStyle) status: NotificationStatus = field(default_factory=UncheckedNotifications) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index d584966af..9d11a4ff5 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -457,26 +457,31 @@ def rich_text(self, block: RichTextBody) -> None: @handles(Divider) def divider(self, block: Divider) -> None: - spans = block.spans + groups = (block.spans,) if block.timestamp is not None: - spans = (*spans, Span(f" · {MessageClock.recorded(block.timestamp).display()[0]}")) + timestamp = MessageClock.recorded(block.timestamp).display()[0] + timed = (*block.spans, Span(f" · {timestamp}")) + groups = ((block.spans, (Span(timestamp),)) + if sum(cell_len(span.text) for span in timed) > max(1, self.width - 8) + else (timed,)) rule, bold = self.theme.style(block.role), Style(bold=True) - title, room = [Segment(" ", rule)], max(0, self.width - 8) - for span in spans: - # A title longer than the rule ends in an ellipsis; what follows is not drawn. - text = _fit(span.text, room) - room -= cell_len(text) - title.append( - Segment( - text, (rule if span.link is None else self.theme.style(span.role)) + bold + link_style(span.link) - ) - ) - if room == 0: - break - title.append(Segment(" ", rule)) - length = sum(segment.cell_length for segment in title) - left = max(0, (self.width - length) // 2) - self.output.append((Segment("─" * left, rule), *title, Segment("─" * max(0, self.width - left - length), rule))) + # Fit actual content, not a guessed phone width. Each semantic span and + # the complete recorded time survive wrapping, including long names. + inset = min(1, (self.width - 1) // 2) + for spans in groups: + content = Text.assemble(*( + (span.text, (rule if span.link is None else self.theme.style(span.role)) + + bold + link_style(span.link)) + for span in spans + )) + for row in self.console.render_lines( + content, self.console.options.update(width=self.width - 2 * inset), pad=False + ): + title = (Segment(" " * inset, rule), *row, Segment(" " * inset, rule)) + length = sum(segment.cell_length for segment in title) + left = max(0, (self.width - length) // 2) + self.output.append((Segment("─" * left, rule), *title, + Segment("─" * max(0, self.width - left - length), rule))) @handles(Summary) def summary(self, block: Summary) -> None: @@ -491,10 +496,14 @@ def summary(self, block: Summary) -> None: @handles(Spans) def spans(self, block: Spans) -> None: + content = Text.assemble(*( + (span.text, self.theme.style(span.role) + link_style(span.link)) + for span in block.spans + )) self.output.extend( - (Segment(line[start : start + self.width]),) - for line in _plain_lines(block.text) - for start in _plain_spans(line, self.width) + tuple(row) for row in self.console.render_lines( + content, self.console.options, pad=False + ) ) @handles(PreparedLines) From 22767133ed69c0c9e5739b9ebf28cc9641b23cd3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 18:40:12 -0400 Subject: [PATCH 02/23] Derive four-edge sidebar geometry and controls from crossing capabilities --- src/toad/application_actions.py | 8 +- src/toad/sidebar_layout.py | 417 +++++++++++++++++----- src/toad/widget_actions.py | 23 ++ src/toad/widgets/side_bar.py | 232 +++++------- src/toad/workspace_chrome.py | 19 +- tests/model/test_sidebar_layout_policy.py | 75 ++-- tests/textual/test_navigation_keys.py | 7 +- tests/textual/test_sidebar_direction.py | 97 +++-- tests/textual/test_sidebar_drag_resize.py | 27 +- 9 files changed, 573 insertions(+), 332 deletions(-) diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index e1c1f9fc2..bdb9b988a 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -20,7 +20,7 @@ from toad.target_commands import TargetContext from toad.thread_actions import ThreadAction from toad.core.session_requests import SessionNavigate -from toad.sidebar_layout import Side +from toad.sidebar_layout import LeftSidebarEdge, RightSidebarEdge, SidebarEdge from agent_comms.errors import RelationViolationError from agent_comms.cli_verbs.new import NewThreadCliCommand from agent_comms.channel_targets import ProjectTarget @@ -234,7 +234,7 @@ class VimHistoryForwardAction(VimHistoryAction): class VimSidebarAction(VimWorkspaceAction, declared_name=None): - side: Side + side: type[SidebarEdge] async def apply(self, app: ToadApp) -> None: app.workspace_chrome.toggle_sidebar(self.side) @@ -243,13 +243,13 @@ async def apply(self, app: ToadApp) -> None: class VimLeftSidebarAction(VimSidebarAction): """Toggle the sidebars on the left edge.""" keys = {VimMode: ("ctrl+shift+h",)} - side = "left" + side = LeftSidebarEdge class VimRightSidebarAction(VimSidebarAction): """Toggle the sidebars on the right edge.""" keys = {VimMode: ("ctrl+shift+l",)} - side = "right" + side = RightSidebarEdge class VimMoveTabAction(VimWorkspaceAction, declared_name=None): diff --git a/src/toad/sidebar_layout.py b/src/toad/sidebar_layout.py index 3f8f73e01..e187a987d 100644 --- a/src/toad/sidebar_layout.py +++ b/src/toad/sidebar_layout.py @@ -1,24 +1,223 @@ -"""One app-owned placement policy for the two independently movable sidebars.""" +"""One app-owned placement policy for independently movable sidebar surfaces.""" from __future__ import annotations -from dataclasses import dataclass, replace -from typing import Literal from abc import abstractmethod +from collections.abc import Iterator +from dataclasses import dataclass, replace +from typing import ClassVar from agent_comms.nominal.declared_family import DeclaredFamily +from textual.css.types import DockEdge +from textual.geometry import Offset, Region, Size, Spacing + + +class SidebarEdge(DeclaredFamily, affix="SidebarEdge"): + """An edge owns its native geometry, resize axis and control placement.""" + + dock: ClassVar[DockEdge] + symbol: ClassVar[str] + sign: ClassVar[int] + minimum: ClassVar[int] + conversation_minimum: ClassVar[int] + collapsed_extent: ClassVar[int] + + @classmethod + @abstractmethod + def axis(cls) -> type[SidebarEdge]: ... + + @classmethod + @abstractmethod + def extent(cls, size: Size) -> int: ... + + @classmethod + @abstractmethod + def coordinate(cls, point: Offset) -> int: ... + + @classmethod + @abstractmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: ... + + @classmethod + @abstractmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: ... + + @classmethod + @abstractmethod + def gutter(cls, extent: int) -> Spacing: ... + + @classmethod + @abstractmethod + def size_toggle(cls, toggle) -> None: ... + + @classmethod + @abstractmethod + def size_handle(cls, handle) -> None: ... + + @classmethod + @abstractmethod + def handle_text(cls, size: Size) -> str: ... + + @classmethod + @abstractmethod + def control_offset(cls, distance: int) -> Offset: ... + + @classmethod + def opposite(cls) -> type[SidebarEdge]: + return next(edge for edge in SidebarEdge.members_with(cls.axis()) if edge is not cls) + + @classmethod + def controls_margin(cls) -> Spacing: + return cls.opposite().gutter(cls.collapsed_extent + 1) + + @classmethod + def toggle_symbol(cls, collapsed: bool) -> str: + return (cls.opposite() if collapsed else cls).symbol + + @classmethod + def order_key(cls, order: int) -> int: + return cls.sign * order + + +class LeadingSidebarEdge: + sign = 1 + + @classmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: + return offset + + +class TrailingSidebarEdge: + sign = -1 + + @classmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: + return viewport - offset - extent + + +class WidthSidebarEdge(SidebarEdge): + minimum = 18 + conversation_minimum = 24 + collapsed_extent = 3 + + @classmethod + def axis(cls) -> type[SidebarEdge]: + return WidthSidebarEdge + + @classmethod + def extent(cls, size: Size) -> int: + return size.width + + @classmethod + def coordinate(cls, point: Offset) -> int: + return point.x + + @classmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: + return Region(cls.origin(size.width, span.offset, span.extent), occupied.top, + span.extent, max(0, size.height - occupied.height)) -type Side = Literal["left", "right"] + @classmethod + def size_toggle(cls, toggle) -> None: + toggle.styles.width, toggle.styles.height = cls.collapsed_extent, "1fr" + + @classmethod + def size_handle(cls, handle) -> None: + handle.styles.width, handle.styles.height = 1, "1fr" + handle.styles.pointer = "ew-resize" + + @classmethod + def handle_text(cls, size: Size) -> str: + return "\n".join("│" for _ in range(size.height)) + + @classmethod + def control_offset(cls, distance: int) -> Offset: + return Offset(-cls.sign * distance, 0) + + +class HeightSidebarEdge(SidebarEdge): + minimum = 9 + conversation_minimum = 8 + collapsed_extent = 1 + + @classmethod + def axis(cls) -> type[SidebarEdge]: + return HeightSidebarEdge + + @classmethod + def extent(cls, size: Size) -> int: + return size.height + + @classmethod + def coordinate(cls, point: Offset) -> int: + return point.y + + @classmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: + return Region(0, cls.origin(size.height, span.offset, span.extent), size.width, span.extent) + + @classmethod + def size_toggle(cls, toggle) -> None: + toggle.styles.width, toggle.styles.height = "1fr", cls.collapsed_extent + + @classmethod + def size_handle(cls, handle) -> None: + handle.styles.width, handle.styles.height = "1fr", 1 + handle.styles.pointer = "ns-resize" + + @classmethod + def handle_text(cls, size: Size) -> str: + return "─" * size.width + + @classmethod + def control_offset(cls, distance: int) -> Offset: + return Offset(0, -cls.sign * distance) + + +class LeftSidebarEdge(LeadingSidebarEdge, WidthSidebarEdge): + dock = "left" + symbol = "<" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, 0, 0, extent) + + +class RightSidebarEdge(TrailingSidebarEdge, WidthSidebarEdge): + dock = "right" + symbol = ">" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, extent, 0, 0) + + +class TopSidebarEdge(LeadingSidebarEdge, HeightSidebarEdge): + dock = "top" + symbol = "↑" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(extent, 0, 0, 0) + + +class BottomSidebarEdge(TrailingSidebarEdge, HeightSidebarEdge): + dock = "bottom" + symbol = "↓" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, 0, extent, 0) class SidebarShift(DeclaredFamily, affix="SidebarShift"): available = True @abstractmethod - def apply(self, layout: "SidebarLayout", identity: str, direction: Side) -> bool: ... + def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: ... @abstractmethod - def tooltip(self, direction: Side) -> str: ... + def tooltip(self, direction: type[SidebarEdge]) -> str: ... class BlockedSidebarShift(SidebarShift): @@ -36,7 +235,7 @@ def apply(self, layout, identity, direction): return layout.swap(identity) def tooltip(self, direction): - return f"Swap with the sidebar to the {direction}" + return f"Swap with the sidebar toward {direction.declared_name}" class MoveSidebarShift(SidebarShift): @@ -44,36 +243,54 @@ def apply(self, layout, identity, direction): return layout.move(identity, direction) def tooltip(self, direction): - return f"Move sidebar to the {direction}" + return f"Move sidebar to {direction.declared_name}" class SidebarPlacementAction(DeclaredFamily, affix="SidebarPlacementAction"): - directional = False + classes = "" label: str + @property + def name(self) -> str: + return self.declared_name + + @classmethod + def offered(cls) -> Iterator[SidebarPlacementAction]: + yield cls() + + @abstractmethod + def apply(self, layout: SidebarLayout, identity: str) -> None: ... + @abstractmethod - def apply(self, layout: "SidebarLayout", identity: str) -> None: ... + def present(self, button, placement: SidebarPlacement, + directions: dict[type[SidebarEdge], SidebarShift]) -> None: ... +@dataclass(frozen=True) class DirectionalSidebarPlacementAction(SidebarPlacementAction): - directional = True + direction: type[SidebarEdge] + classes = "-direction" @property - @abstractmethod - def direction(self) -> Side: ... - - def apply(self, layout, identity): - layout.shift(identity, self.direction) + def name(self) -> str: + return self.direction.declared_name + @property + def label(self) -> str: + return self.direction.symbol -class LeftSidebarPlacementAction(DirectionalSidebarPlacementAction): - direction = "left" - label = "<──" + @classmethod + def offered(cls) -> Iterator[SidebarPlacementAction]: + for edge in SidebarEdge.members_with(SidebarEdge): + yield cls(edge) + def apply(self, layout, identity): + layout.shift(identity, self.direction) -class RightSidebarPlacementAction(DirectionalSidebarPlacementAction): - direction = "right" - label = "──>" + def present(self, button, placement, directions): + action = directions[self.direction] + button.display = action.available + button.tooltip = action.tooltip(self.direction) class FloatSidebarPlacementAction(SidebarPlacementAction): @@ -82,48 +299,53 @@ class FloatSidebarPlacementAction(SidebarPlacementAction): def apply(self, layout, identity): layout.float_mode(identity) + def present(self, button, placement, directions): + button.update("Push" if placement.floating else "Float", layout=False) + button.tooltip = ("Push conversation text" if placement.floating + else "Float over conversation text") + @dataclass(frozen=True, slots=True) class SidebarPlacement: - side: Side - order: int # Zero is the outside edge; a second bar goes on its inside. - width_percent: int + side: type[SidebarEdge] + order: int + extent_percent: int floating: bool = False @dataclass(frozen=True, slots=True) class SidebarGeometry: - x: int - width: int + offset: int + extent: int @dataclass(frozen=True, slots=True) class ResolvedSidebarLayout: - bars: dict[str, SidebarGeometry] - left_gutter: int - right_gutter: int + bars: dict[str, Region] + gutter: Spacing class SidebarLayout: """Placement only; sidebar content and thread navigation retain their owners.""" + minimum_percent = 15 + maximum_percent = 50 + def __init__(self) -> None: self.placements: dict[str, SidebarPlacement] = { - "channels-sidebar": SidebarPlacement("left", 0, 40), - "thread-sidebar": SidebarPlacement("right", 0, 34), + "channels-sidebar": SidebarPlacement(LeftSidebarEdge, 0, 40), + "thread-sidebar": SidebarPlacement(RightSidebarEdge, 0, 34), } def get(self, identity: str) -> SidebarPlacement: return self.placements[identity] - def move(self, identity: str, side: Side) -> bool: - if side not in {"left", "right"}: - raise ValueError("Unknown sidebar edge.") + def move(self, identity: str, side: type[SidebarEdge]) -> bool: current = self.get(identity) - if current.side == side: + if current.side is side: return False peers = [key for key, placement in self.placements.items() - if key != identity and placement.side == side] + if key != identity and placement.side is side] self.placements[identity] = replace(current, side=side, order=len(peers)) self._renumber(current.side) return True @@ -131,7 +353,7 @@ def move(self, identity: str, side: Side) -> bool: def swap(self, identity: str) -> bool: current = self.get(identity) peers = [key for key, placement in self.placements.items() - if key != identity and placement.side == current.side] + if key != identity and placement.side is current.side] if not peers: return False other = peers[0] @@ -139,32 +361,34 @@ def swap(self, identity: str) -> bool: self.placements[other] = replace(self.placements[other], order=current.order) return True - def directions(self, identity: str) -> dict[Side, SidebarShift]: - """Resolve spatial arrows against the current neighbors and outside wall.""" + def directions(self, identity: str) -> dict[type[SidebarEdge], SidebarShift]: + """Each spatial arrow swaps a neighbor, crosses the center or stops at a wall.""" current = self.get(identity) - actions: dict[Side, SidebarShift] = {"left": BlockedSidebarShift(), "right": BlockedSidebarShift()} - for direction in actions: - inward = direction != current.side + actions: dict[type[SidebarEdge], SidebarShift] = {} + for direction in SidebarEdge.members_with(SidebarEdge): + if direction.axis() is not current.side.axis(): + actions[direction] = MoveSidebarShift() + continue + inward = direction is not current.side neighbor_order = current.order + (1 if inward else -1) - if any(key != identity and peer.side == current.side and peer.order == neighbor_order + if any(key != identity and peer.side is current.side and peer.order == neighbor_order for key, peer in self.placements.items()): actions[direction] = SwapSidebarShift() elif inward: actions[direction] = MoveSidebarShift() + else: + actions[direction] = BlockedSidebarShift() return actions - def shift(self, identity: str, direction: Side) -> bool: - """One arrow step: swap with a neighbor, cross the center, or stop at a wall.""" + def shift(self, identity: str, direction: type[SidebarEdge]) -> bool: return self.directions(identity)[direction].apply(self, identity, direction) - def width(self, identity: str, percentage: int) -> bool: - if type(percentage) is not int: - raise ValueError("Sidebar width must be an integer percentage.") + def resize(self, identity: str, percentage: int) -> bool: current = self.get(identity) - percentage = max(15, min(50, percentage)) - if current.width_percent == percentage: + percentage = max(self.minimum_percent, min(self.maximum_percent, percentage)) + if current.extent_percent == percentage: return False - self.placements[identity] = replace(current, width_percent=percentage) + self.placements[identity] = replace(current, extent_percent=percentage) return True def float_mode(self, identity: str) -> bool: @@ -172,51 +396,60 @@ def float_mode(self, identity: str) -> bool: self.placements[identity] = replace(current, floating=not current.floating) return self.placements[identity].floating - def resolve(self, viewport: int, collapsed: dict[str, bool]) -> ResolvedSidebarLayout: - """Pack actual bar widths; Float changes only the conversation's gutter. + def resolve(self, viewport: Size, collapsed: dict[str, bool]) -> ResolvedSidebarLayout: + """Pack each axis once; top/bottom surfaces own the corners. - An inner pushed bar reserves space through its inside edge, including - any outer floating peer. Otherwise conversation text would overlap the - pushed bar. Collapsed handles always reserve their small edge extent. + Float changes only conversation clearance. A pushed inner peer clears + its floating outer peer too; collapsed handles always reserve space. """ - viewport = max(0, viewport) - widths = {key: 3 if collapsed[key] else max(18, viewport * self.get(key).width_percent // 100) - for key in self.placements if key in collapsed} - handles = sum(width for key, width in widths.items() if collapsed[key]) - expanded = [key for key in widths if not collapsed[key]] - budget = max(viewport - 24, min(viewport, handles + 18 * len(expanded))) - wanted = sum(widths[key] for key in expanded) - available = max(0, budget - handles) - if wanted > available: - for key in expanded: - widths[key] = available * widths[key] // wanted - for key in expanded[:available - sum(widths[key] for key in expanded)]: - widths[key] += 1 - if handles > viewport: - for key in widths: - widths[key] = viewport * widths[key] // handles - bars: dict[str, SidebarGeometry] = {} - gutters = {"left": 0, "right": 0} - for side in ("left", "right"): - offset = 0 - for key in sorted((key for key in widths if self.get(key).side == side), - key=lambda key: self.get(key).order): - width = widths[key] - bars[key] = SidebarGeometry(offset if side == "left" else viewport - offset - width, - width) - offset += width - if collapsed[key] or not self.get(key).floating: - gutters[side] = offset - return ResolvedSidebarLayout(bars, gutters["left"], gutters["right"]) - - def _renumber(self, side: Side) -> None: + spans: dict[str, SidebarGeometry] = {} + occupied = gutter = Spacing() + edges = SidebarEdge.members_with(SidebarEdge) + for axis in dict.fromkeys(edge.axis() for edge in edges): + total = max(0, axis.extent(viewport)) + extents = { + key: axis.collapsed_extent if collapsed[key] else max(axis.minimum, total * placement.extent_percent // 100) + for key, placement in self.placements.items() + if key in collapsed and placement.side.axis() is axis + } + handles = sum(extent for key, extent in extents.items() if collapsed[key]) + expanded = [key for key in extents if not collapsed[key]] + budget = max(total - axis.conversation_minimum, + min(total, handles + axis.minimum * len(expanded))) + wanted = sum(extents[key] for key in expanded) + available = max(0, budget - handles) + if wanted > available: + for key in expanded: + extents[key] = available * extents[key] // wanted + for key in expanded[:available - sum(extents[key] for key in expanded)]: + extents[key] += 1 + if handles > total: + for key in extents: + extents[key] = total * extents[key] // handles + for side in SidebarEdge.members_with(axis): + offset = clearance = 0 + for key in sorted((key for key in extents if self.get(key).side is side), + key=lambda key: self.get(key).order): + extent = extents[key] + spans[key] = SidebarGeometry(offset, extent) + offset += extent + if collapsed[key] or not self.get(key).floating: + clearance = offset + occupied += side.gutter(offset) + gutter += side.gutter(clearance) + return ResolvedSidebarLayout( + {key: self.get(key).side.region(viewport, span, occupied) for key, span in spans.items()}, + gutter, + ) + + def _renumber(self, side: type[SidebarEdge]) -> None: names = sorted((key for key, placement in self.placements.items() - if placement.side == side), key=lambda key: self.placements[key].order) + if placement.side is side), key=lambda key: self.placements[key].order) for index, key in enumerate(names): self.placements[key] = replace(self.placements[key], order=index) def ordered(self) -> tuple[str, ...]: + edges = SidebarEdge.members_with(SidebarEdge) return tuple(sorted(self.placements, key=lambda key: - (self.placements[key].side != "left", - self.placements[key].order if self.placements[key].side == "left" - else -self.placements[key].order))) + (edges.index(self.get(key).side), + self.get(key).side.order_key(self.get(key).order)))) diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index e75c10105..f1f10b191 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -314,6 +314,24 @@ class CursorRightAction(HorizontalCursorAction): direction = +1 +class VerticalCursorAction(VisibleAction, declared_name=None): + group = HorizontalCursorAction.group + direction: ClassVar[Literal[-1, 1]] + + async def apply(self, context: MovesVerticalCursor) -> None: + context.cursor_vertical(self.direction) + + +class CursorUpAction(VerticalCursorAction): + keys = {EditingMode: ("up",)} + direction = -1 + + +class CursorDownAction(VerticalCursorAction): + keys = {EditingMode: ("down",)} + direction = +1 + + class SubmitAction(VisibleAction): keys = {EditingMode: ("enter",)} @@ -417,6 +435,11 @@ class MovesHorizontalCursor(DeclaredWidgetActions, actions=(HorizontalCursorActi def cursor_horizontal(self, direction: Literal[-1, 1]) -> None: ... +class MovesVerticalCursor(DeclaredWidgetActions, actions=(VerticalCursorAction,)): + @abstractmethod + def cursor_vertical(self, direction: Literal[-1, 1]) -> None: ... + + class Submits(DeclaredWidgetActions, actions=(SubmitAction,)): @abstractmethod diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index f76639d9e..deb6f8644 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -6,7 +6,7 @@ from toad.core_event_carrier import CoreEventReceiver from toad.core import events as core_events from textual.content import Content -from toad.widget_actions import ApplicationAction, ActivationKeys, Activates, DismissibleControl, WidgetAction, DeclaredWidgetActions, MovesHorizontalCursor +from toad.widget_actions import ApplicationAction, ActivationKeys, Activates, DismissibleControl, WidgetAction, DeclaredWidgetActions, MovesHorizontalCursor, MovesVerticalCursor from textual.document._editing import EditingMode from toad.widgets.comms_menu import ContextMenu from toad.core.preference_events import PreferenceChanged @@ -18,7 +18,7 @@ from textual import containers, events, on, widgets from textual.app import ComposeResult from textual.events import Click -from textual.geometry import Offset +from textual.geometry import Offset, Region from textual.message import Message from textual.reactive import reactive from textual._activation import PrimaryActivation @@ -31,8 +31,8 @@ from toad.widgets.selection import HoverSelection, ColourSelection from toad.session_tracker import SidebarState from toad.sidebar_layout import ( - Side, - SidebarGeometry, + SidebarEdge, + SidebarLayout, SidebarPlacement, SidebarPlacementAction, SidebarShift, @@ -46,12 +46,11 @@ @dataclass(frozen=True) class BarPresentation: - side: Side + side: type[SidebarEdge] order: int floating: bool collapsed: bool directions: tuple[type[SidebarShift], ...] - compact: bool @classmethod def from_placement( @@ -59,8 +58,7 @@ def from_placement( placement: SidebarPlacement, *, collapsed: bool, - directions: dict[Side, SidebarShift], - width: int, + directions: dict[type[SidebarEdge], SidebarShift], ) -> BarPresentation: return cls( placement.side, @@ -68,15 +66,14 @@ def from_placement( placement.floating, collapsed, tuple(type(action) for action in directions.values()), - all(action.available for action in directions.values()) and width - 4 < 21, ) @dataclass(frozen=True) class PresentedBar: presentation: BarPresentation - geometry: SidebarGeometry - width_percent: int + geometry: Region + extent_percent: int class SidebarPanelWidget(Widget): @@ -197,9 +194,6 @@ def paint_highlighted(self): DEFAULT_CSS = """ SideBarToggle { - dock: right; - width: 3; - height: 1fr; content-align: center middle; color: $text-muted; background: $primary 8%; @@ -214,17 +208,15 @@ def __init__(self, *, focus: bool = True) -> None: super().__init__() self.focus = focus - def __init__(self, collapsed: bool = False, *, right: bool = False) -> None: + def __init__(self, collapsed: bool, *, edge: type[SidebarEdge]) -> None: super().__init__() - self.right = right - self.set_collapsed(collapsed) - - def set_collapsed(self, collapsed: bool) -> None: - self.styles.dock = "left" if self.right else "right" - self.offset = (0 if collapsed else 1 if self.right else -1, 0) - text = ( - ("<" if collapsed else ">") if self.right else (">" if collapsed else "<") - ) + self.set_collapsed(collapsed, edge) + + def set_collapsed(self, collapsed: bool, edge: type[SidebarEdge]) -> None: + edge.size_toggle(self) + self.styles.dock = edge.opposite().dock + self.offset = edge.control_offset(0 if collapsed else 1) + text = edge.toggle_symbol(collapsed) if self.content != text: self.update(text, layout=False) tooltip = "Expand sidebar" if collapsed else "Collapse sidebar" @@ -389,7 +381,7 @@ def __init__(self, *args, **kwargs) -> None: def drag_start(self, event: events.MouseDown) -> None: raise NotImplementedError - def drag_to(self, screen_x: int) -> None: + def drag_to(self, position: Offset) -> None: raise NotImplementedError def on_mouse_down(self, event: events.MouseDown) -> None: @@ -402,7 +394,7 @@ def on_mouse_down(self, event: events.MouseDown) -> None: def on_mouse_move(self, event: events.MouseMove) -> None: if self._dragging: event.stop() - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) def can_replace_mouse_move( self, event: events.MouseMove, pending: events.MouseMove @@ -413,7 +405,7 @@ def can_replace_mouse_move( def on_mouse_up(self, event: events.MouseUp) -> None: if event.button == 1 and self._dragging: event.stop() - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) self._dragging = False self.release_mouse() @@ -441,7 +433,7 @@ def __init__(self, minimum: int, maximum: int, value: int) -> None: self.maximum = maximum self.value = value self.reversed = False - self.tooltip = "Drag or use ←/→ to resize (15–50% of screen)" + self.tooltip = f"Drag or use ←/→ to resize ({minimum}–{maximum}% of the placement axis)" def set_range(self, minimum: int, maximum: int, value: int) -> None: self.minimum, self.maximum = minimum, max(minimum, maximum) @@ -458,8 +450,8 @@ def render(self) -> str: line = "".join("●" if index == thumb else "─" for index in range(track)) return f"{line} {label}" if self.reversed else f"{label} {line}" - def drag_to(self, screen_x: int) -> None: - x = screen_x - self._drag_origin_x + def drag_to(self, position: Offset) -> None: + x = position.x - self._drag_origin_x width = self._drag_width reverse = self._drag_reversed label_width = 4 @@ -489,7 +481,7 @@ def drag_start(self, event: events.MouseDown) -> None: self._drag_origin_x = self.region.x self._drag_width = self.size.width self._drag_reversed = self.reversed - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) class SidebarAction(Activates, HoverSelection, widgets.Static, can_focus=True): @@ -497,7 +489,7 @@ class SidebarAction(Activates, HoverSelection, widgets.Static, can_focus=True): DEFAULT_CSS = """ SidebarAction { height: 1; width: auto; padding: 0 1; color: $text-muted; pointer: pointer; } - SidebarAction.-direction { width: 7; content-align: center middle; text-style: bold; } + SidebarAction.-direction { width: 1fr; content-align: center middle; text-style: bold; } """ @dataclass @@ -505,15 +497,14 @@ class Pressed(Message): action: SidebarPlacementAction def __init__(self, action: SidebarPlacementAction) -> None: - super().__init__(action.label, id=f"sidebar-{action.declared_name}") + super().__init__(action.label, id=f"sidebar-{action.name}", classes=action.classes) self.action = action - self.set_class(action.directional, "-direction") def activate(self) -> None: self.post_message(self.Pressed(self.action)) class SidebarResizeHandle( - MovesHorizontalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True + MovesHorizontalCursor, MovesVerticalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True ): """Drag the conversation-facing edge; the placement model owns the width.""" @@ -521,37 +512,43 @@ class SidebarResizeHandle( DEFAULT_CSS = """ SidebarResizeHandle { - dock: right; width: 1; height: 1fr; padding: 0; - color: $success; pointer: ew-resize; + padding: 0; + color: $success; } SidebarResizeHandle:ansi { color: ansi_green; } """ - def __init__(self) -> None: + def __init__(self, edge: type[SidebarEdge]) -> None: super().__init__(id="sidebar-resize-handle") - self.tooltip = "Drag toward the center to widen the sidebar" + edge.size_handle(self) + self.styles.dock = edge.opposite().dock + self.tooltip = "Drag toward the center to expand the sidebar" def render(self) -> str: - return "\n".join("│" for _ in range(self.size.height)) + return self.query_ancestor(SideBar).placement.side.handle_text(self.size) + + def drag_to(self, position: Offset) -> None: + edge = self._drag_edge + extent = self._start_extent + edge.sign * edge.coordinate(position - self._start_point) + self.query_ancestor(SideBar).resize_to(round(100 * extent / max(1, self._drag_viewport))) - def drag_to(self, screen_x: int) -> None: + def resize_by(self, direction: Offset) -> None: bar = self.query_ancestor(SideBar) - if bar.id is None or bar.parent is None: - return - app = self.app - direction = -1 if app.sidebar_layout.get(bar.id).side == "right" else 1 - width = self._start_width + direction * (screen_x - self._start_x) - percent = round(100 * width / max(1, bar.screen.size.width)) - if app.sidebar_layout.width(bar.id, percent): - app.workspace_chrome.refresh_layout() + placement = bar.placement + bar.resize_to(placement.extent_percent + placement.side.sign * placement.side.coordinate(direction)) def cursor_horizontal(self, direction: int) -> None: - bar = self.query_ancestor(SideBar) - bar.width_slider.cursor_horizontal(direction) + self.resize_by(Offset(direction, 0)) + + def cursor_vertical(self, direction: int) -> None: + self.resize_by(Offset(0, direction)) def drag_start(self, event: events.MouseDown) -> None: - self._start_x = event.screen_x - self._start_width = self.query_ancestor(SideBar).size.width + bar = self.query_ancestor(SideBar) + self._drag_edge = bar.placement.side + self._start_point = event.screen_offset + self._start_extent = self._drag_edge.extent(bar.size) + self._drag_viewport = self._drag_edge.extent(bar.screen.size) class ClearSidebarSearchAction(WidgetAction): @@ -596,7 +593,6 @@ class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, SideBar > SidebarViewport { width: 1fr; height: 1fr; - margin: 0 4 0 0; layer: sidebar-content; overflow: auto auto; scrollbar-size: 1 1; @@ -605,21 +601,13 @@ class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, SideBar > #sidebar-controls { width: 1fr; height: auto; - margin: 0 4 0 0; layer: sidebar-content; } - SideBar #sidebar-layout-actions { height: 1; width: 1fr; overflow: hidden hidden; grid-size: 3; grid-columns: 7 7 auto; } - SideBar #sidebar-layout-actions.-compact { - grid-size: 2; grid-columns: 1fr 1fr; height: 2; + SideBar #sidebar-layout-actions { + height: auto; width: 1fr; overflow: hidden hidden; + grid-size: 2; grid-columns: 1fr 1fr; grid-rows: 1; } - SideBar #sidebar-layout-actions.-compact > #sidebar-float { column-span: 2; width: 1fr; } - SideBar.-right { - border-right: none; - border-left: none; - } - SideBar.-right > SideBarToggle { dock: left; } - SideBar.-right > SidebarResizeHandle { dock: left; } - SideBar.-right > SidebarViewport, SideBar.-right > #sidebar-controls { margin: 0 0 0 4; } + SideBar #sidebar-float { column-span: 2; width: 1fr; } """ collapsed = reactive(False) @@ -646,7 +634,6 @@ def __init__( classes: str | None = None, disabled: bool = False, hide: bool = False, - right: bool = False, defer_mount: bool = False, ) -> None: super().__init__(name=name, id=id, classes=classes, disabled=disabled) @@ -655,14 +642,19 @@ def __init__( self.search_input.styles.layer = "sidebar-content" self.panels_viewport: SidebarViewport = SidebarViewport() self.hide = hide - self.right = right - self.width_slider = SidebarSlider(15, 50, 40 if not right else 34) + self.extent_slider = SidebarSlider( + SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, + SidebarLayout.minimum_percent, + ) self._panels_loaded = not defer_mount self._panels_loading = False self._panels_ready = asyncio.Event() self._presented_collapsed: bool | None = None self._presented_layout: PresentedBar | None = None - self.set_class(right, "-right") + + @property + def placement(self) -> SidebarPlacement: + return self.app.sidebar_layout.get(self.id) @property def panels_loaded(self) -> bool: @@ -745,8 +737,8 @@ def on_unmount(self) -> None: self._panels_ready.set() def compose(self) -> ComposeResult: - yield SideBarToggle(self.collapsed, right=self.right) - yield SidebarResizeHandle() + yield SideBarToggle(self.collapsed, edge=self.placement.side) + yield SidebarResizeHandle(self.placement.side) yield self.search_input with self.panels_viewport: if self._panels_loaded: @@ -774,11 +766,12 @@ def _compose_panels(self) -> ComposeResult: ) def _compose_controls(self) -> ComposeResult: - yield self.width_slider + yield self.extent_slider yield containers.Grid( *( - SidebarAction(kind()) + SidebarAction(action) for kind in SidebarPlacementAction.members_with(SidebarPlacementAction) + for action in kind.offered() ), id="sidebar-layout-actions", ) @@ -790,88 +783,59 @@ def on_show(self) -> None: self._present_controls() def _present_controls(self) -> bool: - app = self.app if not self.is_mounted: return False - placement = app.sidebar_layout.get(self.id) + placement = self.placement if self.parent is None or not self.presentation_visible: self._presented_layout = None return False - geometry = SidebarGeometry(self.region.x, self.size.width) - width = geometry.width - directions = app.sidebar_layout.directions(self.id) + directions = self.app.sidebar_layout.directions(self.id) current = PresentedBar( presentation=BarPresentation.from_placement( - placement, collapsed=self.collapsed, directions=directions, width=width + placement, collapsed=self.collapsed, directions=directions ), - geometry=geometry, - width_percent=placement.width_percent, + geometry=self.region, + extent_percent=placement.extent_percent, ) previous = self._presented_layout if current == previous: return False self._presented_layout = current - controls = ( - self.query_one_optional("#sidebar-controls") - if self._panels_loaded - else None - ) - slider = self.width_slider + controls = self.query_one_optional("#sidebar-controls") if self._panels_loaded else None + slider = self.extent_slider if previous is None or previous.presentation != current.presentation: - self.right = placement.side == "right" - self.set_class(self.right, "-right") - self.search_input.styles.margin = ( - (0, 0, 0, 4) if self.right else (0, 4, 0, 0) - ) + edge = placement.side + margin = edge.controls_margin() + self.search_input.styles.margin = margin + self.panels_viewport.styles.margin = margin if toggle := self.query_one_optional(SideBarToggle): - toggle.right = self.right - toggle.set_collapsed(self.collapsed) + toggle.set_collapsed(self.collapsed, edge) if handle := self.query_one_optional(SidebarResizeHandle): + edge.size_handle(handle) + handle.styles.dock = edge.opposite().dock handle.display = not self.collapsed if controls is not None: + controls.styles.margin = margin controls.display = not self.collapsed - actions = controls.query_one_optional("#sidebar-layout-actions") - buttons = { - direction: controls.query_one_optional( - f"#sidebar-{direction}", SidebarAction - ) - for direction in (*directions, "float") - } - if actions is None or any( - button is None for button in buttons.values() - ): - # Mounted parents can precede their composed action rows. - self._presented_layout = None - else: - slider.reversed = self.right - slider.set_range(15, 50, placement.width_percent) - for direction, action in directions.items(): - button = buttons[direction] - button.display = action.available - button.tooltip = action.tooltip(direction) - toggle_mode = buttons["float"] - toggle_mode.update( - "Push" if placement.floating else "Float", layout=False - ) - toggle_mode.tooltip = ( - "Push conversation text" - if placement.floating - else "Float over conversation text" - ) - actions.set_class(current.presentation.compact, "-compact") - if slider.value != placement.width_percent: - slider.value = placement.width_percent + slider.reversed = edge.sign < 0 + slider.set_range(SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, + placement.extent_percent) + for button in controls.query(SidebarAction): + button.action.present(button, placement, directions) + if slider.value != placement.extent_percent: + slider.value = placement.extent_percent slider.refresh() return True + def resize_to(self, percentage: int) -> None: + if self.app.sidebar_layout.resize(self.id, percentage): + self._present_controls() + self.app.workspace_chrome.refresh_layout() + @on(SidebarSlider.Changed) def on_sidebar_slider(self, event: SidebarSlider.Changed) -> None: event.stop() - if self.id is not None: - app = self.app - if app.sidebar_layout.width(self.id, event.value): - self._present_controls() - app.workspace_chrome.refresh_layout() + self.resize_to(event.value) @on(SidebarAction.Pressed) def on_sidebar_action(self, event: SidebarAction.Pressed) -> None: @@ -917,7 +881,7 @@ def watch_collapsed(self, collapsed: bool) -> None: self.tooltip = "Expand sidebar" if collapsed else None toggle = self.query_one_optional(SideBarToggle) if toggle is not None: - toggle.set_collapsed(collapsed) + toggle.set_collapsed(collapsed, self.placement.side) toggle.refresh_paint() for panel in self.panels: panel.widget.sidebar_visibility_changed() @@ -927,7 +891,7 @@ def watch_collapsed(self, collapsed: bool) -> None: self.app.workspace_chrome.refresh_layout() def render(self) -> str: - return ("<" if self.right else ">") if self.collapsed else "" + return self.placement.side.toggle_symbol(self.collapsed) if self.collapsed else "" @handles(PreferenceChanged) async def _settings_changed(self, event: PreferenceChanged) -> None: @@ -980,7 +944,7 @@ def dismiss_control(self) -> None: class ThreadSidebar(SideBar): def __init__(self, *panels, state: SidebarState, **kwargs): self.state = state - super().__init__(*panels, id="thread-sidebar", right=True, hide=True, **kwargs) + super().__init__(*panels, id="thread-sidebar", hide=True, **kwargs) self.collapsed = self.hide @property diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 87991f935..826bf3a69 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -15,7 +15,7 @@ from toad.widget_view import AUTO, FILL, WidgetView from toad.session_presentation import NativeSessionSurface from toad.session_scope import SessionScope -from toad.sidebar_layout import Side +from toad.sidebar_layout import SidebarEdge if TYPE_CHECKING: from toad.app import ToadApp @@ -49,7 +49,7 @@ def presented_sidebars(self): return tuple(bar for bar in (self.channels, *source.sidebars()) if bar.presentation_visible and bar.id in layout.placements) - def toggle_sidebar(self, side: Side) -> None: + def toggle_sidebar(self, side: type[SidebarEdge]) -> None: layout = self.app.sidebar_layout bars = tuple(bar for bar in self.presented_sidebars() if layout.get(bar.id).side == side) @@ -83,23 +83,22 @@ def arrange( layout = self.app.sidebar_layout own = tuple(source.sidebars()) bars = self.presented_sidebars() - resolved = layout.resolve(size.width, {bar.id: bar.collapsed for bar in bars}) + resolved = layout.resolve(size, {bar.id: bar.collapsed for bar in bars}) # The outer host stays full width. Its session's own native layout # clears both gutters, including the shared channel surface. nested = parent is self.channels.parent and any( bar.parent.layout is self for bar in own ) - left = 0 if nested else resolved.left_gutter - right = 0 if nested else resolved.right_gutter + gutter = Spacing() if nested else resolved.gutter placements = [ placement._replace( - region=placement.region + Offset(left, 0), - margin=placement.margin + Spacing(0, right, 0, left), + region=placement.region + Offset(gutter.left, gutter.top), + margin=placement.margin + gutter, ) for placement in super().arrange( parent, [child for child in children if child not in bars], - Size(max(0, size.width - left - right), size.height), + Size(max(0, size.width - gutter.width), max(0, size.height - gutter.height)), greedy, ) ] @@ -109,8 +108,8 @@ def arrange( geometry = resolved.bars[bar.id] placements.append( WidgetPlacement( - Region(0, 0, geometry.width, size.height), - Offset(geometry.x, 0), + geometry.reset_offset, + geometry.offset, Spacing(), bar, order[bar.id], diff --git a/tests/model/test_sidebar_layout_policy.py b/tests/model/test_sidebar_layout_policy.py index c0da18821..5b737eb8a 100644 --- a/tests/model/test_sidebar_layout_policy.py +++ b/tests/model/test_sidebar_layout_policy.py @@ -1,71 +1,92 @@ -"""The placement model cannot duplicate, discard or over-expand a sidebar.""" +"""Placement preserves both sidebars, peer order, axis bounds and conversation clearance.""" from itertools import product import pytest +from textual.geometry import Size -from toad.sidebar_layout import SidebarLayout +from toad.sidebar_layout import LeftSidebarEdge, RightSidebarEdge, SidebarEdge, SidebarLayout -def test_moving_swapping_and_resizing_keep_both_sidebars_and_clamp_width(): +EDGES = SidebarEdge.members_with(SidebarEdge) + + +def test_moving_swapping_and_resizing_keep_both_sidebars_and_clamp_extent(): layout = SidebarLayout() assert layout.ordered() == ("channels-sidebar", "thread-sidebar") - assert layout.move("channels-sidebar", "right") + assert layout.move("channels-sidebar", RightSidebarEdge) assert layout.get("thread-sidebar").order == 0 assert layout.get("channels-sidebar").order == 1 assert layout.swap("channels-sidebar") assert layout.get("channels-sidebar").order == 0 assert layout.get("thread-sidebar").order == 1 assert layout.ordered() == ("thread-sidebar", "channels-sidebar") - assert layout.move("thread-sidebar", "left") + assert layout.move("thread-sidebar", LeftSidebarEdge) assert layout.ordered() == ("thread-sidebar", "channels-sidebar") assert layout.get("thread-sidebar").order == 0 - assert layout.width("channels-sidebar", 70) - assert layout.get("channels-sidebar").width_percent == 50 - assert layout.width("channels-sidebar", 1) - assert layout.get("channels-sidebar").width_percent == 15 + assert layout.resize("channels-sidebar", 70) + assert layout.get("channels-sidebar").extent_percent == layout.maximum_percent + assert layout.resize("channels-sidebar", 1) + assert layout.get("channels-sidebar").extent_percent == layout.minimum_percent assert layout.float_mode("thread-sidebar") is True assert layout.float_mode("thread-sidebar") is False assert set(layout.ordered()) == {"thread-sidebar", "channels-sidebar"} -@pytest.mark.parametrize("side", ["left", "right"]) -def test_two_sidebars_on_one_side_swap_inward_then_cross(side): +@pytest.mark.parametrize("side", EDGES) +def test_two_sidebars_on_one_edge_swap_inward_then_cross(side): pair = SidebarLayout() pair.move("channels-sidebar", side) pair.move("thread-sidebar", side) outer, inner = sorted(pair.placements, key=lambda key: pair.get(key).order) - inward = "right" if side == "left" else "left" + inward = side.opposite() before = dict(pair.placements) assert not pair.shift(outer, side) and pair.placements == before assert pair.shift(outer, inward) - assert pair.get(outer).side == pair.get(inner).side == side + assert pair.get(outer).side is pair.get(inner).side is side assert pair.get(outer).order == 1 and pair.get(inner).order == 0 - assert pair.shift(outer, inward) and pair.get(outer).side == inward + assert pair.shift(outer, inward) and pair.get(outer).side is inward assert not pair.shift(outer, inward) @pytest.mark.parametrize( - "side, width, hide_left, hide_right", list(product(("left", "right"), (76, 120), (False, True), (False, True)))) -def test_float_keeps_order_and_swap_keeps_widths(side, width, hide_left, hide_right): + "side, viewport, hide_channels, hide_thread", + list(product(EDGES, (Size(76, 40), Size(120, 70)), (False, True), (False, True))), +) +def test_float_keeps_order_and_swap_keeps_extents(side, viewport, hide_channels, hide_thread): pair = SidebarLayout() pair.move("channels-sidebar", side) pair.move("thread-sidebar", side) - collapsed = {"channels-sidebar": hide_left, "thread-sidebar": hide_right} - before = pair.resolve(width, collapsed) + collapsed = {"channels-sidebar": hide_channels, "thread-sidebar": hide_thread} + before = pair.resolve(viewport, collapsed) order = pair.ordered() for name in order: pair.float_mode(name) assert pair.ordered() == order, "Float must not reorder sidebars" - assert pair.resolve(width, collapsed).bars == before.bars - physical = sorted(before.bars.values(), key=lambda bar: bar.x) - assert physical[0].x + physical[0].width == physical[1].x - assert physical[0].x >= 0 and physical[1].x + physical[1].width <= width + assert pair.resolve(viewport, collapsed).bars == before.bars + physical = sorted(before.bars.values(), key=lambda bar: side.coordinate(bar.offset)) + assert side.coordinate(physical[0].offset) + side.extent(physical[0].size) == side.coordinate(physical[1].offset) + assert side.coordinate(physical[0].offset) >= 0 + assert side.coordinate(physical[1].offset) + side.extent(physical[1].size) <= side.extent(viewport) for name, hidden in collapsed.items(): if hidden: - assert before.bars[name].width == 3 + assert side.extent(before.bars[name].size) == side.collapsed_extent pair.swap("channels-sidebar") - after = pair.resolve(width, dict(reversed(tuple(collapsed.items())))) - assert {key: bar.width for key, bar in after.bars.items()} == { - key: bar.width for key, bar in before.bars.items() - }, "Swap must not change widths through order-dependent rounding" + after = pair.resolve(viewport, dict(reversed(tuple(collapsed.items())))) + assert {key: side.extent(bar.size) for key, bar in after.bars.items()} == { + key: side.extent(bar.size) for key, bar in before.bars.items() + }, "Swap must not change extents through order-dependent rounding" + + +@pytest.mark.parametrize("channels_edge, thread_edge", list(product(EDGES, repeat=2))) +def test_every_edge_combination_stays_inside_native_viewport(channels_edge, thread_edge): + layout = SidebarLayout() + layout.move("channels-sidebar", channels_edge) + layout.move("thread-sidebar", thread_edge) + viewport = Size(52, 70) + resolved = layout.resolve(viewport, {key: False for key in layout.placements}) + bars = tuple(resolved.bars.values()) + assert all(viewport.region.contains_region(bar) for bar in bars) + assert not bars[0].overlaps(bars[1]) + assert resolved.gutter.width < viewport.width + assert resolved.gutter.height < viewport.height diff --git a/tests/textual/test_navigation_keys.py b/tests/textual/test_navigation_keys.py index fefaa1c5e..132195c27 100644 --- a/tests/textual/test_navigation_keys.py +++ b/tests/textual/test_navigation_keys.py @@ -137,11 +137,12 @@ async def test_sidebar_keys_follow_actual_edges_after_a_pointer_move(workspace): field.value = '' # Use the existing spatial control to move the right bar to the left. - button = next(button for button in thread.query(SidebarAction) - if button.display and button.action.directional and button.action.direction == 'left') + from toad.sidebar_layout import LeftSidebarEdge + + button = thread.query_one('#sidebar-left', SidebarAction) assert await pilot.click(button) await ui.step() - assert not thread.right and not channels.right + assert thread.placement.side is channels.placement.side is LeftSidebarEdge ui.conversation.prompt.focus() assert ui.conversation.prompt.prompt_text_area.editing_mode.allows_application_commands await ui.press('ctrl+shift+l') diff --git a/tests/textual/test_sidebar_direction.py b/tests/textual/test_sidebar_direction.py index c73c3dae5..2ae1c791c 100644 --- a/tests/textual/test_sidebar_direction.py +++ b/tests/textual/test_sidebar_direction.py @@ -1,67 +1,66 @@ -"""Textual-facing: sidebar arrow buttons follow spatial neighbours (walls, inward swaps, outward swaps, cross-centre moves) as laid out by the compositor.""" - -from toad.widgets.side_bar import SideBar, SidebarAction +"""Native sidebar controls reflect the placement owner at every declared edge.""" +import pytest -def arrows(bar: SideBar) -> list[str]: - return [button.render().plain for button in bar.query(SidebarAction) - if button.display and button.action.directional] +from toad.sidebar_layout import SidebarEdge +from toad.widgets.side_bar import SideBar, SidebarAction -async def test_arrow_clicks_follow_spatial_neighbours(workspace): - async with workspace.app(size=(120, 40)) as ui: +@pytest.mark.parametrize("side", SidebarEdge.members_with(SidebarEdge)) +async def test_arrow_clicks_follow_spatial_neighbours(workspace, side): + async with workspace.app(size=(120, 60)) as ui: app, pilot = ui.textual channels = app.screen.query_one("#channels-sidebar", SideBar) thread = app.selected_session.query_one("#thread-sidebar", SideBar) + channels.reveal() thread.reveal() + app.sidebar_layout.move(channels.id, side) + app.sidebar_layout.move(thread.id, side) + app.workspace_chrome.refresh_layout() await ui.step() - async def click(bar: SideBar, label: str) -> None: - button = next(button for button in bar.query(SidebarAction) - if button.display and button.render().plain == label) - assert await pilot.click(button), ( - bar.id, label, button.region, bar.region, - tuple((item.id, item.region, item.collapsed) for item in app.screen.query(SideBar)), - button in app.screen._compositor.visible_widgets) + async def click(bar: SideBar, edge) -> None: + button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) + assert button.display and await pilot.click(button), (bar.id, edge, button.region, bar.region) await ui.step() - assert arrows(channels) == ["──>"] and arrows(thread) == ["<──"], ( - arrows(channels), arrows(thread), ui.visible_text()) - await click(thread, "<──") - assert not channels.right and not thread.right - assert channels.region.right == thread.region.x - assert arrows(channels) == ["──>"], "Wall-adjacent bar must have only the inward arrow" - assert arrows(thread) == ["<──", "──>"] + def check_controls(bar: SideBar) -> None: + for edge, shift in app.sidebar_layout.directions(bar.id).items(): + button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) + assert button.display == shift.available - await click(channels, "──>") - assert not channels.right and not thread.right, "Inward click must swap, not cross the conversation" - assert thread.region.right == channels.region.x - assert arrows(thread) == ["──>"] and arrows(channels) == ["<──", "──>"] - await click(channels, "<──") - assert channels.region.right == thread.region.x - await click(thread, "──>") - assert thread.right and not channels.right + def check_adjacent() -> None: + regions = sorted((channels.region, thread.region), key=lambda region: side.coordinate(region.offset)) + assert side.coordinate(regions[0].offset) + side.extent(regions[0].size) == side.coordinate(regions[1].offset) - await click(channels, "──>") - assert channels.right and thread.right - assert channels.region.right == thread.region.x - assert arrows(thread) == ["<──"] and arrows(channels) == ["<──", "──>"] - await click(thread, "<──") - assert channels.right and thread.right - assert thread.region.right == channels.region.x - assert arrows(channels) == ["<──"] and arrows(thread) == ["<──", "──>"] - await click(thread, "──>") - assert channels.region.right == thread.region.x + # The wall is blocked, the inward arrow swaps, then crosses the centre. + assert channels.placement.order == 0 and thread.placement.order == 1 + check_controls(channels) + check_controls(thread) + check_adjacent() + inward = side.opposite() + await click(channels, inward) + assert channels.placement.side is thread.placement.side is side + assert thread.placement.order == 0 and channels.placement.order == 1 + check_adjacent() + await click(channels, side) + assert channels.placement.order == 0 and thread.placement.order == 1 + await click(thread, inward) + assert thread.placement.side is inward and channels.placement.side is side + await click(channels, inward) + assert channels.placement.side is thread.placement.side is inward + check_controls(channels) + check_controls(thread) + check_adjacent() - # Float and a collapsed neighbour do not change the arrow policy. - await click(thread, "Float") - channels.toggle() + # Float and collapse keep the same canonical peer/action policy. + assert await pilot.click(thread.query_one("#sidebar-float", SidebarAction)) + channels.toggle(focus=False) await ui.step() - assert arrows(thread) == ["<──"] - await click(thread, "<──") - assert thread.right and channels.right - assert thread.region.right == channels.region.x - assert arrows(thread) == ["<──", "──>"] + check_controls(thread) + await click(thread, side) + assert thread.placement.side is channels.placement.side is inward channels.reveal() await ui.step() - assert arrows(channels) == ["<──"] + check_controls(channels) + check_controls(thread) diff --git a/tests/textual/test_sidebar_drag_resize.py b/tests/textual/test_sidebar_drag_resize.py index ad64d7b58..9a2dfa5a3 100644 --- a/tests/textual/test_sidebar_drag_resize.py +++ b/tests/textual/test_sidebar_drag_resize.py @@ -6,27 +6,28 @@ from toad.keymap import KeymapRow from toad.preferences import SETTINGS from toad.widget_actions import CursorRightAction +from toad.sidebar_layout import SidebarEdge, WidthSidebarEdge from toad.widgets.side_bar import ( SideBar, SideBarToggle, SidebarAction, SidebarResizeHandle, SidebarSlider, TabHistoryControls, ) -@pytest.mark.parametrize("side", ["left", "right"]) +@pytest.mark.parametrize("side", SidebarEdge.members_with(WidthSidebarEdge)) async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, side): async with workspace.app(size=(120, 40)) as ui: app, pilot = ui.textual bar = app.screen.query_one("#channels-sidebar", SideBar) app.sidebar_layout.move("channels-sidebar", side) - app.sidebar_layout.width("channels-sidebar", 30) + app.sidebar_layout.resize("channels-sidebar", 30) app.workspace_chrome.refresh_layout() await ui.step() - slider = bar.width_slider + slider = bar.extent_slider handle = bar.query_one(SidebarResizeHandle) toggle = bar.query_one(SideBarToggle) - assert (toggle.region.right == handle.region.x if side == "left" + assert (toggle.region.right == handle.region.x if side.sign > 0 else toggle.region.x == handle.region.right), (side, toggle.region, handle.region) - direction = 1 if side == "left" else -1 - assert slider.reversed == (side == "right") + direction = side.sign + assert slider.reversed == (side.sign < 0) for theme in ("ansi-dark", "textual-dark"): app.theme = theme ui.conversation.prompt.focus() @@ -36,7 +37,7 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s assert await pilot.hover(handle, offset=(0, 3)) await ui.step() assert handle._paint_state.foreground != normal and "hover" in handle.pseudo_classes - for selector in (f"#sidebar-{'right' if side == 'left' else 'left'}", "#sidebar-float"): + for selector in (f"#sidebar-{side.opposite().declared_name}", "#sidebar-float"): button = bar.query_one(selector, SidebarAction) normal = button._paint_state.background assert await pilot.hover(button) @@ -53,14 +54,14 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s await pilot.mouse_up(offset=(start + direction * 12, y)) await ui.step() assert bar.size.width == old_width + 12, (side, bar.size, old_width) - assert slider.value == app.sidebar_layout.get(bar.id).width_percent == 40 + assert slider.value == app.sidebar_layout.get(bar.id).extent_percent == 40 assert not handle._dragging and app.mouse_captured is None # The physical arrow keys and slider endpoints mirror as well. slider.focus() - await ui.press("right" if side == "left" else "left") + await ui.press("right" if side.sign > 0 else "left") assert slider.value == 41 - await ui.press("left" if side == "left" else "right") + await ui.press("left" if side.sign > 0 else "right") assert slider.value == 40 remap = app.settings.ui.keymap.changed(KeymapRow.of(CursorRightAction(), Standard), ("f6",)) app.saved_settings.set(SETTINGS.ui.keymap.bindings, remap.bindings) @@ -72,20 +73,20 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s assert slider.value == 40 + direction await ui.press("left") assert slider.value == 40 - inward = slider.size.width - 1 if side == "left" else 0 + inward = slider.size.width - 1 if side.sign > 0 else 0 assert await pilot.mouse_down(slider, offset=(inward, 0)) assert not app.screen._selecting, "Width slider started a text selection" assert await pilot.mouse_up(slider, offset=(inward, 0)) await ui.step() assert slider.value == 50 and bar.size.width == 60, (side, slider.value, bar.region) - outward = 4 if side == "left" else slider.size.width - 1 + outward = 4 if side.sign > 0 else slider.size.width - 1 # Width changes on press, then the capture owner gets the release. # A final Click may land outside the track it has just resized. assert await pilot.mouse_down(slider, offset=(outward, 0)) await pilot.mouse_up(offset=(slider._drag_origin_x + outward, slider.region.y)) await ui.step() assert slider.value == 15 and bar.size.width == 18 - assert handle.region.x == (bar.region.right - 1 if side == "left" else bar.region.x) + assert handle.region.x == (bar.region.right - 1 if side.sign > 0 else bar.region.x) bar.toggle() await ui.step() assert not handle.display From e532fb496f3a15d69aa0054f4fc8b9094cc6a012 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 18:51:03 -0400 Subject: [PATCH 03/23] Migrate the tool header to the shared variable-height line block --- src/toad/widgets/tool_call.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index e966e2392..963860d2d 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -21,14 +21,14 @@ from toad.widgets.agent_activity import AgentActivityPresentation from toad.widgets.line_markdown import LineMarkdown -from toad.widgets.message_divider import SingleLineBlock +from toad.widgets.message_divider import ConversationLineBlock from toad.widgets.tool_content import ToolContent from toad.app_node import ToadNode -class ToolCallHeader(SingleLineBlock): +class ToolCallHeader(ConversationLineBlock): ALLOW_SELECT = False DEFAULT_CSS = """ ToolCallHeader { From 6675dc725656f7685db583e30f0434c5d6112d3b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 19:31:39 -0400 Subject: [PATCH 04/23] Inherit the cursor grouping and direction contract once --- src/toad/widget_actions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index f1f10b191..ec156c283 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -296,10 +296,14 @@ async def apply(self, context: DismissibleControl) -> None: context.dismiss_control() -class HorizontalCursorAction(VisibleAction, declared_name=None): +class CursorAction(VisibleAction, declared_name=None): + """Select along the widget's cursor axis.""" + group = Binding.Group("Select") direction: ClassVar[Literal[-1, 1]] + +class HorizontalCursorAction(CursorAction, declared_name=None): async def apply(self, context: MovesHorizontalCursor) -> None: context.cursor_horizontal(self.direction) @@ -314,10 +318,7 @@ class CursorRightAction(HorizontalCursorAction): direction = +1 -class VerticalCursorAction(VisibleAction, declared_name=None): - group = HorizontalCursorAction.group - direction: ClassVar[Literal[-1, 1]] - +class VerticalCursorAction(CursorAction, declared_name=None): async def apply(self, context: MovesVerticalCursor) -> None: context.cursor_vertical(self.direction) From 2961e45b16109b1d9819718f5d165e6cd5f95c1b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 19:43:04 -0400 Subject: [PATCH 05/23] Own sidebar content clearance once and preserve resize minimums --- src/toad/sidebar_layout.py | 7 +- src/toad/widgets/side_bar.py | 81 ++++++++++++----------- tests/model/test_sidebar_layout_policy.py | 11 +++ tests/textual/test_sidebar_projection.py | 2 +- 4 files changed, 60 insertions(+), 41 deletions(-) diff --git a/src/toad/sidebar_layout.py b/src/toad/sidebar_layout.py index e187a987d..e2b2c6f09 100644 --- a/src/toad/sidebar_layout.py +++ b/src/toad/sidebar_layout.py @@ -67,7 +67,7 @@ def opposite(cls) -> type[SidebarEdge]: return next(edge for edge in SidebarEdge.members_with(cls.axis()) if edge is not cls) @classmethod - def controls_margin(cls) -> Spacing: + def content_padding(cls) -> Spacing: return cls.opposite().gutter(cls.collapsed_extent + 1) @classmethod @@ -419,8 +419,11 @@ def resolve(self, viewport: Size, collapsed: dict[str, bool]) -> ResolvedSidebar wanted = sum(extents[key] for key in expanded) available = max(0, budget - handles) if wanted > available: + minimum = min(axis.minimum, available // len(expanded)) + surplus = available - minimum * len(expanded) + demand = wanted - minimum * len(expanded) for key in expanded: - extents[key] = available * extents[key] // wanted + extents[key] = minimum + surplus * (extents[key] - minimum) // demand for key in expanded[:available - sum(extents[key] for key in expanded)]: extents[key] += 1 if handles > total: diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index deb6f8644..b645b04ad 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -183,7 +183,7 @@ def apply(self, context: SideBarToggle) -> None: class SideBarToggle(HoverSelection, DeclaredWidgetActions, PrimaryActivation, widgets.Static, actions=(ToggleSidebarAction,)): - """Full-height collapse target beside the conversation-facing resize edge.""" + """Collapse target spanning the conversation-facing resize edge.""" @property def paint_highlighted(self): @@ -506,7 +506,7 @@ def activate(self) -> None: class SidebarResizeHandle( MovesHorizontalCursor, MovesVerticalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True ): - """Drag the conversation-facing edge; the placement model owns the width.""" + """Drag the conversation-facing edge; the placement model owns the extent.""" highlight_foreground = ForegroundColour @@ -575,6 +575,35 @@ async def apply(self, sidebar: SideBar) -> None: sidebar.search_input.focus() +class SidebarContentStack(containers.Vertical): + """One padded flow for content; padding never intercepts the outer handles.""" + + hit_test_transparent = True + + DEFAULT_CSS = """ + SidebarContentStack { + width: 1fr; height: 1fr; layer: sidebar-content; + overflow: hidden hidden; + } + SidebarContentStack > SidebarViewport { + width: 1fr; + height: 1fr; + overflow: auto auto; + scrollbar-size: 1 1; + scrollbar-gutter: stable; + } + SidebarContentStack > #sidebar-controls { + width: 1fr; + height: auto; + } + SidebarContentStack #sidebar-layout-actions { + height: auto; width: 1fr; overflow: hidden hidden; + grid-size: 2; grid-columns: 1fr 1fr; grid-rows: 1; + } + SidebarContentStack #sidebar-float { column-span: 2; width: 1fr; } + """ + + class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, containers.Vertical, ToadNode, actions=(SearchSidebarAction,)): tone = SidebarToneColour @@ -590,24 +619,6 @@ class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, width: auto; border: none; } - SideBar > SidebarViewport { - width: 1fr; - height: 1fr; - layer: sidebar-content; - overflow: auto auto; - scrollbar-size: 1 1; - scrollbar-gutter: stable; - } - SideBar > #sidebar-controls { - width: 1fr; - height: auto; - layer: sidebar-content; - } - SideBar #sidebar-layout-actions { - height: auto; width: 1fr; overflow: hidden hidden; - grid-size: 2; grid-columns: 1fr 1fr; grid-rows: 1; - } - SideBar #sidebar-float { column-span: 2; width: 1fr; } """ collapsed = reactive(False) @@ -638,8 +649,8 @@ def __init__( ) -> None: super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.panels: list[SideBar.Panel] = [*panels] + self.content_stack = SidebarContentStack() self.search_input = SidebarSearch(placeholder="Search", compact=True) - self.search_input.styles.layer = "sidebar-content" self.panels_viewport: SidebarViewport = SidebarViewport() self.hide = hide self.extent_slider = SidebarSlider( @@ -739,13 +750,14 @@ def on_unmount(self) -> None: def compose(self) -> ComposeResult: yield SideBarToggle(self.collapsed, edge=self.placement.side) yield SidebarResizeHandle(self.placement.side) - yield self.search_input - with self.panels_viewport: - if self._panels_loaded: - yield from self._compose_panels() - with containers.Vertical(id="sidebar-controls"): - if self._panels_loaded: - yield from self._compose_controls() + with self.content_stack: + yield self.search_input + with self.panels_viewport: + if self._panels_loaded: + yield from self._compose_panels() + with containers.Vertical(id="sidebar-controls"): + if self._panels_loaded: + yield from self._compose_controls() @on(widgets.Input.Changed) def search_changed(self, event: widgets.Input.Changed) -> None: @@ -805,9 +817,7 @@ def _present_controls(self) -> bool: slider = self.extent_slider if previous is None or previous.presentation != current.presentation: edge = placement.side - margin = edge.controls_margin() - self.search_input.styles.margin = margin - self.panels_viewport.styles.margin = margin + self.content_stack.styles.padding = edge.content_padding() if toggle := self.query_one_optional(SideBarToggle): toggle.set_collapsed(self.collapsed, edge) if handle := self.query_one_optional(SidebarResizeHandle): @@ -815,8 +825,6 @@ def _present_controls(self) -> bool: handle.styles.dock = edge.opposite().dock handle.display = not self.collapsed if controls is not None: - controls.styles.margin = margin - controls.display = not self.collapsed slider.reversed = edge.sign < 0 slider.set_range(SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, placement.extent_percent) @@ -873,11 +881,8 @@ def watch_collapsed(self, collapsed: bool) -> None: if not collapsed and self.is_mounted: self.schedule_hydration() self._presented_collapsed = collapsed - # Panel visibility is independent of its native placement. - self.panels_viewport.display = not collapsed - self.search_input.display = not collapsed - if controls := self.query_one_optional("#sidebar-controls"): - controls.display = not collapsed + # One content flow hides without retiring its retained children. + self.content_stack.display = not collapsed self.tooltip = "Expand sidebar" if collapsed else None toggle = self.query_one_optional(SideBarToggle) if toggle is not None: diff --git a/tests/model/test_sidebar_layout_policy.py b/tests/model/test_sidebar_layout_policy.py index 5b737eb8a..e35bbd1b8 100644 --- a/tests/model/test_sidebar_layout_policy.py +++ b/tests/model/test_sidebar_layout_policy.py @@ -78,6 +78,17 @@ def test_float_keeps_order_and_swap_keeps_extents(side, viewport, hide_channels, }, "Swap must not change extents through order-dependent rounding" +@pytest.mark.parametrize("side, viewport", list(product(EDGES, (Size(51, 27), Size(36, 18))))) +def test_shrinking_preserves_each_axis_minimum_when_both_controls_fit(side, viewport): + layout = SidebarLayout() + layout.move("channels-sidebar", side) + layout.move("thread-sidebar", side.opposite()) + layout.resize("channels-sidebar", layout.maximum_percent) + layout.resize("thread-sidebar", layout.minimum_percent) + resolved = layout.resolve(viewport, {key: False for key in layout.placements}) + assert all(side.extent(bar.size) >= side.minimum for bar in resolved.bars.values()) + + @pytest.mark.parametrize("channels_edge, thread_edge", list(product(EDGES, repeat=2))) def test_every_edge_combination_stays_inside_native_viewport(channels_edge, thread_edge): layout = SidebarLayout() diff --git a/tests/textual/test_sidebar_projection.py b/tests/textual/test_sidebar_projection.py index 158c730cf..88efa66ac 100644 --- a/tests/textual/test_sidebar_projection.py +++ b/tests/textual/test_sidebar_projection.py @@ -24,7 +24,7 @@ def _display(self, screen, renderable): sidebar = screen.query_one_optional("#channels-sidebar", SideBar) if sidebar is not None: self.frames.append((self.selected_mode, sidebar.collapsed, sidebar.size.width, - sidebar.panels_viewport.display)) + sidebar.content_stack.display)) return result From 3450c661eff1c388bbffb31d5a9c074490cd1e46 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 19:55:04 -0400 Subject: [PATCH 06/23] Resize from native parent geometry without percentage round trips --- src/toad/widgets/side_bar.py | 10 ++++++---- tests/textual/test_sidebar_drag_resize.py | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index b645b04ad..99d999890 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -529,8 +529,10 @@ def render(self) -> str: def drag_to(self, position: Offset) -> None: edge = self._drag_edge - extent = self._start_extent + edge.sign * edge.coordinate(position - self._start_point) - self.query_ancestor(SideBar).resize_to(round(100 * extent / max(1, self._drag_viewport))) + delta = edge.sign * edge.coordinate(position - self._start_point) + self.query_ancestor(SideBar).resize_to( + self._start_percent + round(100 * delta / max(1, self._drag_viewport)) + ) def resize_by(self, direction: Offset) -> None: bar = self.query_ancestor(SideBar) @@ -547,8 +549,8 @@ def drag_start(self, event: events.MouseDown) -> None: bar = self.query_ancestor(SideBar) self._drag_edge = bar.placement.side self._start_point = event.screen_offset - self._start_extent = self._drag_edge.extent(bar.size) - self._drag_viewport = self._drag_edge.extent(bar.screen.size) + self._start_percent = bar.placement.extent_percent + self._drag_viewport = self._drag_edge.extent(bar.query_ancestor(Widget).content_size) class ClearSidebarSearchAction(WidgetAction): diff --git a/tests/textual/test_sidebar_drag_resize.py b/tests/textual/test_sidebar_drag_resize.py index 9a2dfa5a3..93ed4eefb 100644 --- a/tests/textual/test_sidebar_drag_resize.py +++ b/tests/textual/test_sidebar_drag_resize.py @@ -1,4 +1,4 @@ -"""Textual-facing: pointer capture, drag, hover styling and keyboard resize of a sidebar from either edge, with the width slider mirrored.""" +"""Textual-facing: captured resize, hover and keys; zero-motion release preserves intent on every edge.""" import pytest @@ -94,3 +94,22 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s bar.reveal() await ui.step() assert handle.display + + +@pytest.mark.parametrize("side", SidebarEdge.members_with(SidebarEdge)) +async def test_zero_motion_resize_keeps_requested_percentage_on_every_edge(workspace, side): + async with workspace.app(size=(51, 27)) as ui: + app, pilot = ui.textual + bar = app.screen.query_one("#channels-sidebar", SideBar) + app.sidebar_layout.move(bar.id, side) + app.sidebar_layout.resize(bar.id, 40) + bar.reveal() + app.workspace_chrome.refresh_layout() + await ui.step() + handle = bar.query_one(SidebarResizeHandle) + before = bar.region + assert await pilot.click(handle) + await ui.step() + assert bar.placement.extent_percent == 40 + assert bar.region == before + assert not handle._dragging and app.mouse_captured is None From c698fdaae5aa415dfb94297cb571459d05767c8e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 22:11:32 -0400 Subject: [PATCH 07/23] Own session sidebars before native composition and through retirement --- src/toad/screens/comms.py | 39 +++++++++----------- src/toad/screens/main.py | 14 +++---- src/toad/screens/session_view.py | 27 ++++++++++++++ src/toad/workspace_chrome.py | 6 +-- tests/textual/test_initial_prompt_handoff.py | 5 +++ 5 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 2a21c160b..c000c54f2 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -2,7 +2,7 @@ from toad.core_event_carrier import CoreEventMessage from toad.core import events as core_events from toad.core import session_requests -from toad.screens.session_view import SessionView +from toad.screens.session_view import SidebarSessionView from toad.project_path_owner import CheckoutRoot import asyncio from functools import partial @@ -24,7 +24,6 @@ from toad.widgets.comms_chat import CommsChatView from toad.core.input_events import SelectHistoricalIdentity from toad.widgets.comms_sidebar import CoordinationStatus, CommsSidebar -from toad.session_tracker import SidebarState from toad.navigation_context import NavigationContext from toad.navigation_target import HistoryTarget, NavigationTarget, SelectTarget from toad.widgets.session_tabs import SessionsTabs @@ -78,7 +77,7 @@ def apply(self, context: CommsScreen) -> None: chat.prepare_prompt() -class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, ToadNode, can_focus=False, +class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SidebarSessionView[ThreadSidebar], ToadNode, can_focus=False, actions=(ViewAction, ReturnToViewAction, CloseViewAction)): """A channel or DM represented as a native concurrent Toad session.""" @@ -102,7 +101,6 @@ def __init__( self._identity = SessionIdentity.for_thread(me, owner_root) self.conversation = conversation self.wire_root = wire_root - self._thread_sidebar_state = SidebarState() self._content_ready = asyncio.Event() self._content_error: BaseException | None = None self._content_loaded = False @@ -143,9 +141,21 @@ def rebind_project(self, project: Checkout) -> None: chat.project_path = project chat.working_directory = str(project) - def sidebars(self): - sidebar = self.query_one_optional(ThreadSidebar) - return (sidebar,) if sidebar is not None else () + def make_thread_sidebar(self) -> ThreadSidebar: + return ThreadSidebar( + SideBar.Panel( + "Connection", + CoordinationStatus(), + id="coordination-panel", + ), + SideBar.Panel( + "Comms", + ThreadCommsSidebar(), + id="thread-comms-panel", + header_control=RelationshipSort(), + ), + state=self._thread_sidebar_state, + ) def compose(self) -> ComposeResult: content = ( @@ -156,20 +166,7 @@ def compose(self) -> ComposeResult: ) ) yield containers.Center( - ThreadSidebar( - SideBar.Panel( - "Connection", - CoordinationStatus(), - id="coordination-panel", - ), - SideBar.Panel( - "Comms", - ThreadCommsSidebar(), - id="thread-comms-panel", - header_control=RelationshipSort(), - ), - state=self._thread_sidebar_state, - ), + self.thread_sidebar, containers.Vertical(*content, id="comms-content"), ).set_styles(layout=self.app.workspace_chrome) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 85b263577..18853344c 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -46,10 +46,10 @@ ThreadTarget, ) from toad.screens.mcp_inventory import MCPInventoryScreen -from toad.screens.session_view import SessionView +from toad.screens.session_view import SidebarSessionView from toad.session_presentation import OperationalSessionPresentation from toad.session_scope import AgentBinding, NoAgent, ViewSessionOrigin -from toad.session_tracker import SessionDetails, SidebarState +from toad.session_tracker import SessionDetails from toad.tab_identity.history_origin import SessionHistoryOrigin from toad.thread_identity import NoThreadIdentity, ThreadIdentity from toad.widgets.channels_sidebar import ChannelsSidebar @@ -294,7 +294,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, ToadNode, +class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SidebarSessionView[SessionThreadSidebar], ToadNode, scopes={HiddenActions: (SessionNavigationAction,)}, can_focus=False): footer_compact = True @@ -310,7 +310,6 @@ class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, Vi def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: return self.content.keyboard_scopes(self) - thread_sidebar = getters.query_one(SessionThreadSidebar) @property def side_bar(self): @@ -332,7 +331,6 @@ def __init__( self._identity = binding.initial_identity(NoThreadIdentity()) self._session_pk = session_pk self._initial_prompt = initial_prompt - self._thread_sidebar_state = SidebarState() self._project_panel: ProjectPanel | None = None self.content: ScreenContent = OpeningContent() self.presentation = OperationalSessionPresentation() @@ -436,12 +434,12 @@ def activate_session(self) -> None: if self._project_panel is not None: self.call_after_refresh(self._project_panel.refresh_if_visible) - def sidebars(self): - return (self.thread_sidebar,) + def make_thread_sidebar(self) -> SessionThreadSidebar: + return SessionThreadSidebar(self) def compose(self) -> ComposeResult: with containers.Center().set_styles(layout=self.app.workspace_chrome): - yield SessionThreadSidebar(self) + yield self.thread_sidebar with containers.Vertical(id="session-content"): yield self.content.render(self) yield self.presentation.compose_content(self) diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index af91b4bda..04a94593c 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -1,5 +1,7 @@ """Logical session surfaces hosted by the one native WorkspaceScreen.""" +from abc import abstractmethod +from functools import cached_property from textual.containers import Vertical from textual.reactive import reactive from contextlib import contextmanager @@ -10,6 +12,8 @@ from toad.thread_identity import SessionIdentity, NoThreadIdentity from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope from toad.navigation_target import NavigationTarget, NoTarget +from toad.session_tracker import SidebarState +from toad.widgets.side_bar import ThreadSidebar from toad.widget_actions import DeclaredWidgetActions from toad.widgets.message_notifications import ( NothingToRead, @@ -158,3 +162,26 @@ async def close_presentation(self) -> None: def retained_native_presentations(self): """Declare accepted native trees; non-native views own no such trees.""" return () + + +class SidebarSessionView[SidebarT: ThreadSidebar](SessionView): + """One session-owned sidebar survives construction, mounting and retirement. + + Composition mounts this resource; it does not create its identity. Chrome + may ask for membership before the native tree exists, and close still owns + the same resource after it has left that tree. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._thread_sidebar_state = SidebarState() + + @abstractmethod + def make_thread_sidebar(self) -> SidebarT: ... + + @cached_property + def thread_sidebar(self) -> SidebarT: + return self.make_thread_sidebar() + + def sidebars(self) -> tuple[SidebarT, ...]: + return (self.thread_sidebar,) diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 826bf3a69..3516d7b79 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -47,7 +47,7 @@ def presented_sidebars(self): source = self.app.workspace_sessions.source layout = self.app.sidebar_layout return tuple(bar for bar in (self.channels, *source.sidebars()) - if bar.presentation_visible and bar.id in layout.placements) + if bar.is_mounted and bar.presentation_visible and bar.id in layout.placements) def toggle_sidebar(self, side: type[SidebarEdge]) -> None: layout = self.app.sidebar_layout @@ -79,15 +79,13 @@ def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: """Place chrome and conversation directly in the native layout pass.""" - source = self.app.workspace_sessions.source layout = self.app.sidebar_layout - own = tuple(source.sidebars()) bars = self.presented_sidebars() resolved = layout.resolve(size, {bar.id: bar.collapsed for bar in bars}) # The outer host stays full width. Its session's own native layout # clears both gutters, including the shared channel surface. nested = parent is self.channels.parent and any( - bar.parent.layout is self for bar in own + bar.parent is not parent and bar.parent.layout is self for bar in bars ) gutter = Spacing() if nested else resolved.gutter placements = [ diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index b93029ad9..49e23555e 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -43,7 +43,10 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th async with workspace.app(size=(110, 35)) as ui: app, _pilot = ui.textual owner = ObservedLaunchScreen(Checkout(workspace.project), initial_prompt="INITIAL_LOCAL_UI_EVENT") + sidebar = owner.thread_sidebar + assert owner.sidebars() == (sidebar,) and not sidebar.is_mounted await app.session_navigation.new(await saved_tab(app), lambda: owner) + assert owner.thread_sidebar is sidebar await owner.wait_content_ready() await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() @@ -62,6 +65,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(app.project_dir)) await app.selected_session.wait_content_ready() await owner.presentation.evict(owner) + assert owner.thread_sidebar is sidebar assert view._initial_prompt is None assert owner.presentation.state.initial_prompt == "NEWEST_PENDING_LOCAL_UI_EVENT" assert owner.presentation.widget is None @@ -92,6 +96,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} assert isinstance(owner.conversation.agent_binding, NoAgentConnection) and not owner.conversation.submissions.active await app.session_navigation.close(owner.id) + assert owner.thread_sidebar is sidebar assert owner.presentation.widget is None and owner.presentation.state is None assert owner.id not in app.workspace_sessions.views assert ui.failure() is None From 1953fd01045ee592ddc3c5481e360b4bcf7e3fe4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 22:31:51 -0400 Subject: [PATCH 08/23] Derive sidebar presentation from current native attachment --- src/toad/widgets/side_bar.py | 41 ++++++++++---------- src/toad/workspace_chrome.py | 7 +--- tests/textual/test_initial_prompt_handoff.py | 2 + 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 99d999890..695820116 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -697,13 +697,16 @@ def on_mount(self) -> None: self.schedule_hydration() def schedule_hydration(self) -> None: - if not self._panels_loaded and not self._panels_loading: + if not self._panels_loaded and not self._panels_loading and self.presentation_visible: self.screen.frame_presentation.defer(self, self._start_hydration) @property def presentation_visible(self) -> bool: + """Native attachment owns whether this retained resource may present.""" return ( - self.screen.is_current + self.is_attached + and not self.is_leaving + and self.screen.is_current and self.display and all( ancestor.display @@ -716,9 +719,7 @@ def _start_hydration(self) -> None: if ( self._panels_loaded or self._panels_loading - or not self.is_attached or not self.presentation_visible - or self.is_leaving ): return self._panels_loading = True @@ -727,12 +728,12 @@ def _start_hydration(self) -> None: async def _hydrate_panels(self) -> None: try: await self.panels_viewport.mount_all(self._compose_panels()) - if not self.is_attached or self.is_leaving or not self.presentation_visible: + if not self.presentation_visible: return controls = self.query_one_optional("#sidebar-controls") if controls is not None: await controls.mount_all(self._compose_controls()) - if self.is_attached and not self.is_leaving and self.presentation_visible: + if self.presentation_visible: self._panels_loaded = True self._presented_layout = None await self.panels_hydrated() @@ -794,15 +795,13 @@ def on_resize(self, event: events.Resize) -> None: self._present_controls() def on_show(self) -> None: - self._present_controls() + self.restore_navigation() def _present_controls(self) -> bool: - if not self.is_mounted: - return False - placement = self.placement - if self.parent is None or not self.presentation_visible: + if not self.presentation_visible: self._presented_layout = None return False + placement = self.placement directions = self.app.sidebar_layout.directions(self.id) current = PresentedBar( presentation=BarPresentation.from_placement( @@ -875,12 +874,12 @@ def restore_navigation(self) -> bool: return changed def watch_collapsed(self, collapsed: bool) -> None: - if self.is_mounted and not self.presentation_visible: - # Keep shared intent current without resizing every hidden transcript. - # Activation applies the latest value inside its render transaction; - # an open/close round trip can therefore keep unchanged geometry. + if not self.presentation_visible: + # Keep shared intent current without native work before attachment, + # while hidden or during retirement. Activation applies the latest + # value inside its render transaction. return - if not collapsed and self.is_mounted: + if not collapsed: self.schedule_hydration() self._presented_collapsed = collapsed # One content flow hides without retiring its retained children. @@ -893,9 +892,8 @@ def watch_collapsed(self, collapsed: bool) -> None: for panel in self.panels: panel.widget.sidebar_visibility_changed() self._presented_layout = None - if self.is_mounted: - self._present_controls() - self.app.workspace_chrome.refresh_layout() + self._present_controls() + self.app.workspace_chrome.refresh_layout() def render(self) -> str: return self.placement.side.toggle_symbol(self.collapsed) if self.collapsed else "" @@ -918,12 +916,13 @@ def on_click(self, event: Click) -> None: @on(events.Enter) @on(events.Leave) def gutter_hover_changed(self) -> None: - self.query_one(SideBarToggle).refresh_paint() + if self.presentation_visible: + self.query_one(SideBarToggle).refresh_paint() def toggle(self, *, focus: bool = True) -> None: collapsed = not self.collapsed return_focus = collapsed and (focus or self.has_focus_within) - if return_focus and self.is_mounted and self.screen.is_current: + if return_focus and self.presentation_visible: # Return input focus before native Hide reconstructs its focus chain. self.screen.focus_sidebar_return() self.store_collapsed(collapsed) diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 3516d7b79..a168070b4 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -47,7 +47,7 @@ def presented_sidebars(self): source = self.app.workspace_sessions.source layout = self.app.sidebar_layout return tuple(bar for bar in (self.channels, *source.sidebars()) - if bar.is_mounted and bar.presentation_visible and bar.id in layout.placements) + if bar.presentation_visible and bar.id in layout.placements) def toggle_sidebar(self, side: type[SidebarEdge]) -> None: layout = self.app.sidebar_layout @@ -69,10 +69,7 @@ async def prepare_navigation(self, screen) -> bool: def refresh_layout(self) -> None: """Placement changes retire both native parent arrangements.""" - source = self.app.workspace_sessions.source - for parent in { - bar.parent for bar in (self.channels, *source.sidebars()) if bar.is_mounted - }: + for parent in {bar.parent for bar in self.presented_sidebars()}: parent.refresh(repaint=False, layout=True) def arrange( diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index 49e23555e..65ba6120c 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -45,6 +45,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th owner = ObservedLaunchScreen(Checkout(workspace.project), initial_prompt="INITIAL_LOCAL_UI_EVENT") sidebar = owner.thread_sidebar assert owner.sidebars() == (sidebar,) and not sidebar.is_mounted + assert not sidebar.presentation_visible await app.session_navigation.new(await saved_tab(app), lambda: owner) assert owner.thread_sidebar is sidebar await owner.wait_content_ready() @@ -97,6 +98,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th assert isinstance(owner.conversation.agent_binding, NoAgentConnection) and not owner.conversation.submissions.active await app.session_navigation.close(owner.id) assert owner.thread_sidebar is sidebar + assert not sidebar.presentation_visible assert owner.presentation.widget is None and owner.presentation.state is None assert owner.id not in app.workspace_sessions.views assert ui.failure() is None From 92261ec1fb2535ef1ab0cb3d76fb3d002154c7b8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 10:56:32 -0500 Subject: [PATCH 09/23] Constrain sidebar headers and repair native fixture contracts --- src/toad/widgets/side_bar.py | 4 +-- src/toad/widgets/sidebar_viewport.py | 28 +++++++++++++++++--- tests/textual/test_initial_prompt_handoff.py | 23 ++++++++-------- tests/textual/test_navigation_keys.py | 13 +++++++-- tests/textual/test_sidebar_direction.py | 4 +++ tests/textual/test_sidebar_drag_resize.py | 4 +-- tests/textual/test_sidebar_retirement.py | 5 ++-- 7 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 695820116..fccf39b23 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -18,7 +18,7 @@ from textual import containers, events, on, widgets from textual.app import ComposeResult from textual.events import Click -from textual.geometry import Offset, Region +from textual.geometry import Offset from textual.message import Message from textual.reactive import reactive from textual._activation import PrimaryActivation @@ -72,7 +72,6 @@ def from_placement( @dataclass(frozen=True) class PresentedBar: presentation: BarPresentation - geometry: Region extent_percent: int @@ -807,7 +806,6 @@ def _present_controls(self) -> bool: presentation=BarPresentation.from_placement( placement, collapsed=self.collapsed, directions=directions ), - geometry=self.region, extent_percent=placement.extent_percent, ) previous = self._presented_layout diff --git a/src/toad/widgets/sidebar_viewport.py b/src/toad/widgets/sidebar_viewport.py index a317e448a..ec5d666fb 100644 --- a/src/toad/widgets/sidebar_viewport.py +++ b/src/toad/widgets/sidebar_viewport.py @@ -2,17 +2,31 @@ from __future__ import annotations +from fractions import Fraction + +from textual.box_model import BoxModel from textual.containers import HorizontalGroup, VerticalScroll +from textual.geometry import Size class SidebarHeader(HorizontalGroup): DEFAULT_CSS = "SidebarHeader { width: 100%; }" + def _get_box_model( + self, container: Size, viewport: Size, + width_fraction: Fraction, height_fraction: Fraction, + constrain_width: bool = False, greedy: bool = True, + ) -> BoxModel: + # The immediate auto-width group belongs to the wide body. Header + # controls instead consume the enclosing scrollport's native width. + width = self.query_ancestor(SidebarViewport).scrollable_content_region.width + return super()._get_box_model( + container.with_width(width), viewport, width_fraction, height_fraction, + constrain_width, greedy, + ) + def on_mount(self) -> None: - viewport = next((node for node in self.walk_ancestors() - if isinstance(node, SidebarViewport)), None) - if viewport is not None: - viewport.align_header(self) + self.query_ancestor(SidebarViewport).align_header(self) class SidebarViewport(VerticalScroll): @@ -39,6 +53,12 @@ def on_mount(self) -> None: self.watch(self, "scroll_x", self._align_headers, init=False) self._align_headers() + def on_resize(self) -> None: + # Native resize commits the scrollport, including its scrollbar gutter. + # Invalidate only dependent headers; never store a second pixel width. + for header in self.query(SidebarHeader): + header.refresh(repaint=False, layout=True) + def align_header(self, header: SidebarHeader) -> None: # The content still owns its full intrinsic width. Counter only its # horizontal scroll for these one-line headers, without pinning y or diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index 65ba6120c..991e617e7 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -10,17 +10,10 @@ from toad.core.input_events import UserInputSubmitted from toad.screens.main import MainScreen -from toad.session_presentation import OperationalSessionSources +from toad.session_presentation import SessionViewState from support.ui import saved_tab -class ObservedLaunchSources(OperationalSessionSources): - def make_conversation(self, screen): - conversation = super().make_conversation(screen) - screen.subscriptions.append(conversation.core_publications.subscribe(UserInputSubmitted, screen.observed)) - return conversation - - class ObservedLaunchScreen(MainScreen): """Records the conversation's published inputs; creation and handlers stay native.""" @@ -29,7 +22,13 @@ def __init__(self, *args, **kwargs): self.inputs = Counter() self.publication = asyncio.Event() self.subscriptions = [] - self.presentation.sources = ObservedLaunchSources() + + def make_conversation(self): + conversation = super().make_conversation() + self.subscriptions.append( + conversation.core_publications.subscribe(UserInputSubmitted, self.observed) + ) + return conversation def observed(self, event, subscription): assert not event.shell @@ -76,7 +75,8 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await ui.step() restored = owner.conversation assert restored is not view and isinstance(restored.agent_binding, NoAgentConnection) - assert restored._initial_prompt is None and owner.presentation.state is None + assert restored._initial_prompt is None + assert type(owner.presentation.state) is SessionViewState assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} editor = restored.prompt.prompt_text_area assert editor.document is document and editor.history is undo @@ -99,6 +99,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await app.session_navigation.close(owner.id) assert owner.thread_sidebar is sidebar assert not sidebar.presentation_visible - assert owner.presentation.widget is None and owner.presentation.state is None + assert owner.presentation.widget is None + assert type(owner.presentation.state) is SessionViewState assert owner.id not in app.workspace_sessions.views assert ui.failure() is None diff --git a/tests/textual/test_navigation_keys.py b/tests/textual/test_navigation_keys.py index 132195c27..621987a6b 100644 --- a/tests/textual/test_navigation_keys.py +++ b/tests/textual/test_navigation_keys.py @@ -79,9 +79,13 @@ async def navigate(key, expected): assert editor.text == 'HJKL' assert ui.selected_tab == third assert app.tab_order.names == originals - await ui.press('ctrl+shift+k') - assert editor.text == '' + # Neovim owns Insert chords: Ctrl-K starts a digraph, not the + # native TextArea's delete-line command. The workspace must yield. + await ui.press('ctrl+shift+k', 'A', 'E') + assert editor.text == 'HJKLÆ' assert ui.selected_tab == third and app.tab_order.names == originals + await ui.press('ctrl+u') + assert editor.text == '' await ui.press('H', 'J', 'K', 'L') await ui.press('escape') @@ -177,4 +181,9 @@ async def test_workspace_keys_do_not_interrupt_editor_sequences(workspace): assert ui.selected_tab == selected and editor.has_focus await ui.press('escape', 'd', 'H') assert ui.selected_tab == selected and editor.has_focus + # H is a real linewise motion under the pending delete operator. + # Its edit and undo must happen here, never turn into tab navigation. + assert editor.text == '' + await ui.press('u') assert editor.text == 'abcHdef' + assert ui.selected_tab == selected and editor.has_focus diff --git a/tests/textual/test_sidebar_direction.py b/tests/textual/test_sidebar_direction.py index 2ae1c791c..2905b7b36 100644 --- a/tests/textual/test_sidebar_direction.py +++ b/tests/textual/test_sidebar_direction.py @@ -14,6 +14,10 @@ async def test_arrow_clicks_follow_spatial_neighbours(workspace, side): thread = app.selected_session.query_one("#thread-sidebar", SideBar) channels.reveal() thread.reveal() + # Vacate the destination before establishing the requested outer/inner + # order. Moving an existing occupant to its own edge is a no-op. + app.sidebar_layout.move(channels.id, side.opposite()) + app.sidebar_layout.move(thread.id, side.opposite()) app.sidebar_layout.move(channels.id, side) app.sidebar_layout.move(thread.id, side) app.workspace_chrome.refresh_layout() diff --git a/tests/textual/test_sidebar_drag_resize.py b/tests/textual/test_sidebar_drag_resize.py index 93ed4eefb..deaa9085f 100644 --- a/tests/textual/test_sidebar_drag_resize.py +++ b/tests/textual/test_sidebar_drag_resize.py @@ -5,7 +5,7 @@ from toad.key_modes import Standard from toad.keymap import KeymapRow from toad.preferences import SETTINGS -from toad.widget_actions import CursorRightAction +from textual.keys import RightKey from toad.sidebar_layout import SidebarEdge, WidthSidebarEdge from toad.widgets.side_bar import ( SideBar, SideBarToggle, SidebarAction, SidebarResizeHandle, SidebarSlider, TabHistoryControls, @@ -63,7 +63,7 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s assert slider.value == 41 await ui.press("left" if side.sign > 0 else "right") assert slider.value == 40 - remap = app.settings.ui.keymap.changed(KeymapRow.of(CursorRightAction(), Standard), ("f6",)) + remap = app.settings.ui.keymap.changed(KeymapRow.of(RightKey(), Standard), ("f6",)) app.saved_settings.set(SETTINGS.ui.keymap.bindings, remap.bindings) for control in (slider, handle): control.focus() diff --git a/tests/textual/test_sidebar_retirement.py b/tests/textual/test_sidebar_retirement.py index bf63a99da..f4b8e11f0 100644 --- a/tests/textual/test_sidebar_retirement.py +++ b/tests/textual/test_sidebar_retirement.py @@ -4,6 +4,7 @@ import gc from weakref import ref +from agent_comms.repo import Checkout from agent_comms.relationships import ( AddRelationshipEdit, CollaboratingRelationshipGroup, @@ -132,7 +133,7 @@ async def exercise(ui, screen, root): assert state.selected == chosen saved = [ref(panel.widget) for panel in sidebar.panels] tree_ref = ref(tree) - await sidebar.retire_presentation() + await sidebar.retire_presentation(screen) assert all( widget() is panel.widget for widget, panel in zip(saved, sidebar.panels, strict=True) @@ -235,7 +236,7 @@ async def test_panels_show_the_current_project_after_the_source_project_changes_ replacement = workspace.project.parent / "replacement-project" replacement.mkdir() (replacement / "new-source.txt").write_text("Current filesystem") - screen.project_path = replacement + screen.project_path = Checkout(replacement) await sidebar.prepare_presentation() await sidebar.wait_content_ready() tree = await prepare_project(ui, sidebar) From 232894118bc878f369f39f9f3047aa07d8a2bade Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 12:31:28 -0500 Subject: [PATCH 10/23] Project sidebar controls through the shared placement refresh --- src/toad/widgets/side_bar.py | 13 +------------ src/toad/workspace_chrome.py | 7 +++++-- tests/textual/test_sidebar_drag_resize.py | 5 +++-- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index fccf39b23..41af27110 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -790,13 +790,10 @@ def _compose_controls(self) -> ComposeResult: id="sidebar-layout-actions", ) - def on_resize(self, event: events.Resize) -> None: - self._present_controls() - def on_show(self) -> None: self.restore_navigation() - def _present_controls(self) -> bool: + def present_controls(self) -> bool: if not self.presentation_visible: self._presented_layout = None return False @@ -836,7 +833,6 @@ def _present_controls(self) -> bool: def resize_to(self, percentage: int) -> None: if self.app.sidebar_layout.resize(self.id, percentage): - self._present_controls() self.app.workspace_chrome.refresh_layout() @on(SidebarSlider.Changed) @@ -851,11 +847,6 @@ def on_sidebar_action(self, event: SidebarAction.Pressed) -> None: return app = self.app event.action.apply(app.sidebar_layout, self.id) - for bar in ( - app.workspace_chrome.channels, - *app.workspace_sessions.source.sidebars(), - ): - bar._present_controls() app.workspace_chrome.refresh_layout() def restore_navigation(self) -> bool: @@ -863,7 +854,6 @@ def restore_navigation(self) -> bool: if changed: self.watch_collapsed(self.collapsed) else: - self._present_controls() self.app.workspace_chrome.refresh_layout() navigation = self.navigation for panel in self.query(SideBarCollapsible): @@ -890,7 +880,6 @@ def watch_collapsed(self, collapsed: bool) -> None: for panel in self.panels: panel.widget.sidebar_visibility_changed() self._presented_layout = None - self._present_controls() self.app.workspace_chrome.refresh_layout() def render(self) -> str: diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index a168070b4..5c916bb4f 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -68,8 +68,11 @@ async def prepare_navigation(self, screen) -> bool: return changed def refresh_layout(self) -> None: - """Placement changes retire both native parent arrangements.""" - for parent in {bar.parent for bar in self.presented_sidebars()}: + """Project placement intent before retiring both native arrangements.""" + bars = self.presented_sidebars() + for bar in bars: + bar.present_controls() + for parent in {bar.parent for bar in bars}: parent.refresh(repaint=False, layout=True) def arrange( diff --git a/tests/textual/test_sidebar_drag_resize.py b/tests/textual/test_sidebar_drag_resize.py index deaa9085f..4f226e924 100644 --- a/tests/textual/test_sidebar_drag_resize.py +++ b/tests/textual/test_sidebar_drag_resize.py @@ -63,13 +63,14 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s assert slider.value == 41 await ui.press("left" if side.sign > 0 else "right") assert slider.value == 40 - remap = app.settings.ui.keymap.changed(KeymapRow.of(RightKey(), Standard), ("f6",)) + remapped_key = "ctrl+f6" # Plain f6 already belongs to TextArea's Select line. + remap = app.settings.ui.keymap.changed(KeymapRow.of(RightKey(), Standard), (remapped_key,)) app.saved_settings.set(SETTINGS.ui.keymap.bindings, remap.bindings) for control in (slider, handle): control.focus() await ui.press("right") assert slider.value == 40 - await ui.press("f6") + await ui.press(remapped_key) assert slider.value == 40 + direction await ui.press("left") assert slider.value == 40 From 09734e3e3c0e1181fa37b7f210b72b68d53e1ab3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 12:50:00 -0500 Subject: [PATCH 11/23] Use the thread deletion owner in successor fixtures --- tests/textual/test_conversation_value.py | 2 +- tests/textual/test_hidden_session_retirement.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index d13e1f935..1964d34d6 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -190,7 +190,7 @@ async def test_recreated_dm_peer_retires_the_old_view_and_opens_a_new_incarnatio def replace_peer(): registry = workspace.comms.registry registry.unregister(peer.name) - registry.remove_originals((registry.require(peer.name),)) + workspace.comms.threads.delete(peer.name) return registry.declare(Thread( peer.name, peer.tags, peer.worktree, created_at=peer.created_at + 1, )) diff --git a/tests/textual/test_hidden_session_retirement.py b/tests/textual/test_hidden_session_retirement.py index 92ac5d6c4..6441608fd 100644 --- a/tests/textual/test_hidden_session_retirement.py +++ b/tests/textual/test_hidden_session_retirement.py @@ -120,7 +120,7 @@ async def test_a_same_name_successor_does_not_keep_the_deleted_threads_hidden_se _, gone = app.session_navigation.get(modes["agent-3"]).original_threads(app.session_navigation)[0] old = registry.require(gone.name) registry.unregister(old.name) - registry.remove_originals((registry.require(old.name),)) + workspace.comms.threads.delete(old.name) registry.declare(Thread(old.name, old.tags, old.worktree, created_at=old.created_at + 1)) await app.session_navigation.retire_missing() await ui.until(lambda: app.session_navigation.get(modes["agent-3"]) is None, seconds=15, From 608673f8372b86e09a6e7f3333740ffc136a695d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 13:23:51 -0500 Subject: [PATCH 12/23] Keep project and read owners in UI fixtures --- tests/app/test_channel_display_basis.py | 26 ++++-- tests/textual/test_channel_scope_rows.py | 8 +- ...test_divider_only_viewport_stays_unread.py | 84 ++++++++++--------- tests/textual/test_saved_history_coverage.py | 2 +- tests/textual/test_scroll_down_bounce.py | 2 +- tests/textual/test_sent_message_drawn_once.py | 2 +- 6 files changed, 69 insertions(+), 55 deletions(-) diff --git a/tests/app/test_channel_display_basis.py b/tests/app/test_channel_display_basis.py index 925a25dc8..ec4761506 100644 --- a/tests/app/test_channel_display_basis.py +++ b/tests/app/test_channel_display_basis.py @@ -1,5 +1,7 @@ """A channel marks read exactly the messages whose text it showed, never the history it left out.""" +from agent_comms.read_basis import ChannelConversation from agent_comms.repo import Checkout +from agent_comms.ui_model.unread import ExactUnread async def test_shown_messages_are_read_exactly_and_hidden_expansion_stays_unread(workspace): @@ -7,14 +9,15 @@ async def test_shown_messages_are_read_exactly_and_hidden_expansion_stays_unread workspace.thread(name, *tags, running=True) comms = workspace.comms viewer = workspace.viewer - project = str(workspace.project) + project = Checkout(workspace.project) hidden = comms.messaging.send("bob", "carol", "older hidden DM").message # Every line of a message names it, so a message whose text is partly on screen is recognisable. messages = [comms.messaging.send("alice", "#team", "\n".join(f"channel {i:02d} line" for _ in range(12))).message for i in range(12)] - def seen(): - return comms.bus.reads.seen_sequences(viewer, comms.registry.snapshot()) + ledger = comms.bus.reads + snapshot = comms.registry.snapshot() + channel = ChannelConversation("#team") async with workspace.app(size=(120, 40)) as ui: owner = ui.selected_tab @@ -24,14 +27,21 @@ def seen(): def shown(): return {message.seq for i, message in enumerate(messages) if ui.shows(f"channel {i:02d} line")} - await ui.until(lambda: seen() == shown(), why="the shown messages to be marked read") + await ui.until(lambda: all( + (message.seq in ledger.cursor(viewer, snapshot).for_conversation(channel)) + == (message.seq in shown()) for message in messages + ), why="the shown messages to be marked read") # Earlier messages were read from Core for scrolling, but never shown. assert 0 < len(shown()) < len(messages) and not ui.shows("channel 00 line") - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == len(messages) - len(shown()) - assert hidden.seq not in seen() + assert comms.views.viewer_snapshot(project).channel_unread["#team"] == ExactUnread( + count=len(messages) - len(shown())) + assert hidden.seq not in ledger.cursor(viewer, snapshot).for_conversation( + ledger.conversation(hidden, snapshot)) await ui.select_tab(owner) comms.channels.set_channel_any_mode("#team", True) comms.channels.update_tags("bob", add=frozenset({"team"})) # Expanding a hidden tab's view never manufactures read facts. - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] >= len(messages) - len(shown()) + 1 - assert hidden.seq not in seen() + assert comms.views.viewer_snapshot(project).channel_unread["#team"] == ExactUnread( + count=len(messages) - len(shown()) + 1) + assert hidden.seq not in ledger.cursor(viewer, snapshot).for_conversation( + ledger.conversation(hidden, snapshot)) diff --git a/tests/textual/test_channel_scope_rows.py b/tests/textual/test_channel_scope_rows.py index b8e10e58e..c899e3cb1 100644 --- a/tests/textual/test_channel_scope_rows.py +++ b/tests/textual/test_channel_scope_rows.py @@ -11,11 +11,11 @@ async def test_any_mode_channel_rows_include_older_messages_after_a_refresh(work for name, tags in (("alice", ("team",)), ("bob", ()), ("carol", ()), ("dave", ())): workspace.thread(name, *tags, running=True) comms = workspace.comms - project = str(workspace.project) + viewer = comms.views.display_viewer(Checkout(workspace.project)) comms.messaging.send("carol", "dave", "older newly visible DM") comms.messaging.send("alice", "#team", "current channel message") comms.channels.set_channel_any_mode("#team", True) - assert [m.body for m in comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(project))).messages] == [ + assert [m.body for m in comms.views.channel_display_page("#team", viewer=viewer).messages] == [ "current channel message"] async with workspace.app(size=(100, 30)) as ui: @@ -30,9 +30,9 @@ async def test_any_mode_channel_rows_include_older_messages_after_a_refresh(work await ui.app.coordination_access.stop_observing() history = chat.message_history source, snapshot = history.page_source, history.source_snapshot() - marked = comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(project))) + marked = comms.views.channel_display_page("#team", viewer=viewer) comms.channels.update_tags("carol", add=frozenset({"team"})) - expanded = comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(project))) + expanded = comms.views.channel_display_page("#team", viewer=viewer) assert [m.body for m in expanded.messages][:2] == ["older newly visible DM", "current channel message"] await history._mark_page(marked, (), snapshot) await ui.until(lambda: history.page_source is not source, diff --git a/tests/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index f12b88a91..a6e4108d1 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -1,54 +1,58 @@ """Textual-facing: whether a channel message counts as read depends on which rows Textual painted, and a divider painted without its text is not enough.""" from agent_comms.repo import Checkout -from unittest.mock import patch +from agent_comms.ui_model.unread import ExactUnread from support.textual_steps import text_rows_of from toad.widgets.comms_chat import CommsChatView -from toad.widgets.wire_history import WireHistory async def test_divider_without_body_leaves_the_message_unread(workspace): workspace.thread("peer", "team") comms = workspace.comms - project = str(workspace.project) + project = Checkout(workspace.project) for index in range(7): comms.messaging.send("peer", "#team", f"earlier {index} " + "body " * 20) - comms.views.mark_user_view_read("#team", worktree=Checkout(project)) - last = comms.messaging.send("peer", "#team", "LAST " + "body " * 250).message - - # Hold read marks while arranging the divider-only viewport; the first - # viewport shows the text and may legitimately mark it. - mark_visible = WireHistory.mark_visible - with patch.object(WireHistory, "mark_visible"): - async with workspace.app(size=(80, 22)) as ui: - app, _ = ui.textual - await ui.open_channel("#team", actor="peer") - chat = app.screen.query_one(CommsChatView) - history, window = chat.message_history, chat.window - await ui.until(lambda: text_rows_of(chat, last) is not None, seconds=5, why="the last message laid out") - # Its caption is the row above its text: make that the last row in view. Going up there reads an - # earlier page and redraws rows; arrange the viewport again until the history is idle with it in place. - def caption_is_last_row(): - rows = text_rows_of(chat, last) - return (history.state.accepts_source_work and rows is not None - and rows.start - window.scroll_y == window.outer_size.height) - - for _ in range(10): - await ui.until(lambda: history.state.accepts_source_work and text_rows_of(chat, last) is not None, - why="the history idle") - await ui.step(.2) - if caption_is_last_row(): - break - window.scroll_to(y=text_rows_of(chat, last).start - window.outer_size.height, - animate=False, immediate=True) - await ui.step() - assert caption_is_last_row() - assert not ui.shows("LAST") - assert any(fragment.message.seq == last.seq for fragment in history.visible_fragments()) - assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) - - mark_visible(history) - await ui.until(lambda: not history.marking, why="marking to settle") + comms.views.mark_user_view_read("#team", worktree=project) + + async with workspace.app(size=(80, 22)) as ui: + app, _ = ui.textual + await ui.open_channel("#team", actor="peer") + chat = app.screen.query_one(CommsChatView) + history, window = chat.message_history, chat.window + # Move away from the tail before the new message exists. Its text must + # never paint during setup; no patched read-marker can hide a mistake. + window.scroll_to(y=0, animate=False, immediate=True) + await ui.step() + assert not window.follows_tail + last = comms.messaging.send("peer", "#team", "LAST " + "body " * 250).message + await ui.until(lambda: text_rows_of(chat, last) is not None, + seconds=5, why="the last message laid out") + assert not ui.shows("LAST") + + # Its caption is above its text: place that caption at the viewport's + # last row. Paging can change the layout, so follow the committed rows. + def caption_is_last_row(): + rows = text_rows_of(chat, last) + return (history.state.accepts_source_work and rows is not None + and rows.start - window.scroll_y == window.outer_size.height) + + for _ in range(10): + await ui.until(lambda: history.state.accepts_source_work and text_rows_of(chat, last) is not None, + why="the history idle") await ui.step(.2) - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == 1 + if caption_is_last_row(): + break + window.scroll_to(y=text_rows_of(chat, last).start - window.outer_size.height, + animate=False, immediate=True) + await ui.step() + assert caption_is_last_row() + assert not ui.shows("LAST") + assert any(fragment.message.seq == last.seq for fragment in history.visible_fragments()) + assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) + + history.mark_visible() + if (worker := history._read_worker) is not None: + await worker.wait() + await ui.step(.2) + assert comms.views.viewer_snapshot(project).channel_unread["#team"] == ExactUnread(count=1) diff --git a/tests/textual/test_saved_history_coverage.py b/tests/textual/test_saved_history_coverage.py index ff9b7479c..7c5ee75c8 100644 --- a/tests/textual/test_saved_history_coverage.py +++ b/tests/textual/test_saved_history_coverage.py @@ -30,7 +30,7 @@ async def saved_history(workspace, ui): root = str(workspace.comms.root) await ui.app.session_navigation.new( MailboxTab(owner.incarnation, root), - partial(MainScreen.from_thread, workspace.project, root, owner.incarnation), + partial(MainScreen.from_thread, owner.worktree, root, owner.incarnation), original=(root, owner.incarnation), ) conversation = ui.conversation diff --git a/tests/textual/test_scroll_down_bounce.py b/tests/textual/test_scroll_down_bounce.py index baede7257..e88dac19b 100644 --- a/tests/textual/test_scroll_down_bounce.py +++ b/tests/textual/test_scroll_down_bounce.py @@ -94,7 +94,7 @@ async def test_scroll_down_through_evicted_pages_never_moves_up(workspace, recor ui.app.saved_settings.set(SETTINGS.ui.history_buffer_viewports, 1) await ui.app.session_navigation.new( MailboxTab(owner.incarnation, root), - partial(MainScreen.from_thread, workspace.project, root, owner.incarnation), + partial(MainScreen.from_thread, owner.worktree, root, owner.incarnation), original=(root, owner.incarnation)) await ui.until(lambda: ui.conversation.transcript.reports_coverage, why="the retained transcript to load") diff --git a/tests/textual/test_sent_message_drawn_once.py b/tests/textual/test_sent_message_drawn_once.py index 768358ef1..37812f915 100644 --- a/tests/textual/test_sent_message_drawn_once.py +++ b/tests/textual/test_sent_message_drawn_once.py @@ -32,7 +32,7 @@ async def test_a_start_notice_after_the_saved_history_draws_the_message_once(wor root = str(workspace.comms.root) await ui.app.session_navigation.new( MailboxTab(owner.incarnation, root), - partial(MainScreen.from_thread, workspace.project, root, owner.incarnation), + partial(MainScreen.from_thread, owner.worktree, root, owner.incarnation), original=(root, owner.incarnation), ) view = ui.conversation From 08bf52ea03f181ab30f050e5afecbd705339e6c3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 13:37:07 -0500 Subject: [PATCH 13/23] Ask reader positions and read cursors in paint fixtures --- tests/support/textual_steps.py | 2 +- tests/textual/test_channel_partial_paint.py | 66 +++++++++++++-------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/tests/support/textual_steps.py b/tests/support/textual_steps.py index e0f2d2c56..b9b073f12 100644 --- a/tests/support/textual_steps.py +++ b/tests/support/textual_steps.py @@ -96,7 +96,7 @@ def text_rows_of(chat, message) -> range | None: the window is not on them until that layout ends. """ window = chat.window - if window.reader_position is not None or window.screen._stepped_layout is not None: + if not window.reader_position.laid_out(window) or window.screen._stepped_layout is not None: return None for page in chat.message_history.pages: for fragment, first, lines in page._layout: diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 4886825f5..1aee0fdb5 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,6 +1,6 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" -from support.textual_steps import text_rows_of +from agent_comms.read_basis import ChannelConversation async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): @@ -11,41 +11,59 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ comms = workspace.comms viewer = workspace.viewer rows = [comms.messaging.send("peer", "#team", f"row {i}\n" + "body\n" * 10).message for i in range(8)] - - def seen(): - return comms.bus.reads.seen_sequences(viewer, comms.registry.snapshot()) + ledger = comms.bus.reads + snapshot = comms.registry.snapshot() + channel = ChannelConversation("#team") async with workspace.app(size=(90, 24)) as ui: app = ui.textual[0] await ui.open_channel("#team", actor=viewer) chat = app.screen.query_one(CommsChatView) history, window = chat.message_history, chat.window - await ui.until(lambda: seen() and not history.marking, why="the first viewport to be marked") + await ui.until(lambda: any(row.seq in ledger.cursor( + viewer, snapshot).for_conversation(channel) for row in rows), + why="the first viewport to be marked") + await history._read_worker.wait() in_view = {fragment.message.seq for fragment in history.read_fragments()} - assert 0 < len(seen()) < len(rows) - assert seen() == in_view - # Messages beyond the first viewport stay unread until their text is scrolled to. + assert 0 < len(in_view) < len(rows) + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all((message.seq in cursor) == (message.seq in in_view) for message in rows) + # Address each original message through the history's own source + # fragments; revealing its head prepares virtualized rows on demand. for message in reversed(rows): - if message.seq in seen(): + if message.seq in ledger.cursor(viewer, snapshot).for_conversation(channel): continue - if text_rows_of(chat, message) is None: - # Earlier messages are laid out as the reader nears them. - window.scroll_to(y=0, animate=False, immediate=True) - await ui.until(lambda: text_rows_of(chat, message) is not None, why=f"row {message.seq} laid out") - window.scroll_to(y=text_rows_of(chat, message).start, animate=False, immediate=True) - await ui.until(lambda: message.seq in seen(), why=f"row {message.seq} to be marked once scrolled to") - assert seen() == {row.seq for row in rows} + fragment = next(part for part in history.fragments + if part.message.view_key == message.view_key) + for _ in rows: + if history.draws_fragment(fragment): + break + window.scroll_page_up(animate=False) + await ui.step() + await ui.until(lambda: window.reader_position.laid_out(window), + why="the scrolled viewport to finish layout") + await history.reveal_message_head(fragment, lambda: True) + history.show_fragment(fragment) + await ui.until(lambda: message.seq in ledger.cursor( + viewer, snapshot).for_conversation(channel), + why=f"row {message.seq} to be marked once scrolled to") + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all(row.seq in cursor for row in rows) # A wholly read history has nothing left to mark: no further worker or route read. - await ui.until(lambda: not history.marking, why="marking to settle") - assert all(fragment.evidence is None for fragment in history.wire_fragments) + await history._read_worker.wait() + assert all(fragment.evidence is None for fragment in history.fragments) + completed = history._read_worker for _ in range(50): history.mark_visible() - assert not history.marking - recorded = tuple(message.view_key for message in history.messages) + assert history._read_worker is completed + window.scroll_end(animate=False, immediate=True) + await ui.until(lambda: ui.shows("row 7"), why="the newest row after returning to the bottom") + recorded = tuple(fragment.message.view_key for fragment in history.fragments) await ui.press("ctrl+t") - await ui.until(lambda: isinstance(history.style, MarkdownMessageStyle), why="the Markdown style") + await ui.until(lambda: isinstance(history.page_source.loader.style, MarkdownMessageStyle), why="the Markdown style") assert ui.shows("row 7") await ui.press("ctrl+t") - await ui.until(lambda: isinstance(history.style, IrcMessageStyle), why="the IRC style") - assert tuple(message.view_key for message in history.messages) == recorded - assert seen() == {row.seq for row in rows} + await ui.until(lambda: isinstance(history.page_source.loader.style, IrcMessageStyle), why="the IRC style") + assert tuple(fragment.message.view_key for fragment in history.fragments) == recorded + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all(row.seq in cursor for row in rows) From edc108643fcade86b57adfc057446e08f0d5be75 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 13:50:27 -0500 Subject: [PATCH 14/23] Drive paint fixtures through committed paging intent --- tests/textual/test_channel_partial_paint.py | 8 +++++--- tests/textual/test_divider_only_viewport_stays_unread.py | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 1aee0fdb5..93494c67e 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -38,10 +38,12 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ for _ in rows: if history.draws_fragment(fragment): break - window.scroll_page_up(animate=False) + window.action_page_up() await ui.step() - await ui.until(lambda: window.reader_position.laid_out(window), - why="the scrolled viewport to finish layout") + await ui.until(lambda: history.state.accepts_source_work + and window.reader_position.laid_out(window), + why="the scrolled viewport to finish preparation and layout") + assert history.draws_fragment(fragment), f"Message {message.seq} was not prepared by paging" await history.reveal_message_head(fragment, lambda: True) history.show_fragment(fragment) await ui.until(lambda: message.seq in ledger.cursor( diff --git a/tests/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index a6e4108d1..db655fcb3 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -20,9 +20,14 @@ async def test_divider_without_body_leaves_the_message_unread(workspace): await ui.open_channel("#team", actor="peer") chat = app.screen.query_one(CommsChatView) history, window = chat.message_history, chat.window + # The initial source must have committed scrollable rows before moving + # the reader; opening the tab alone does not establish that state. + await ui.until(lambda: history.state.accepts_source_work and window.max_scroll_y > 0 + and window.reader_position.laid_out(window), why="the initial scrollable history") # Move away from the tail before the new message exists. Its text must # never paint during setup; no patched read-marker can hide a mistake. - window.scroll_to(y=0, animate=False, immediate=True) + window.release_anchor() + window.scroll_home(animate=False, immediate=True) await ui.step() assert not window.follows_tail last = comms.messaging.send("peer", "#team", "LAST " + "body " * 250).message From d318ab3f4568ce38b27879421c692b1fa4f01d6d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 13:55:50 -0500 Subject: [PATCH 15/23] Respect lazy row preparation and latest-message intent in paint fixtures --- tests/textual/test_channel_partial_paint.py | 13 +++++++++++-- .../test_divider_only_viewport_stays_unread.py | 12 ++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 93494c67e..6b9e04d22 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -58,8 +58,17 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ for _ in range(50): history.mark_visible() assert history._read_worker is completed - window.scroll_end(animate=False, immediate=True) - await ui.until(lambda: ui.shows("row 7"), why="the newest row after returning to the bottom") + window.action_scroll_end() + await ui.until(lambda: history.state.accepts_source_work and any( + part.message.view_key == rows[-1].view_key for part in history.drawn_fragments), + why="the latest message prepared by End") + # End shows the newest bottom viewport, not necessarily the first line + # of a tall message. Reveal that head before checking style preservation. + latest = next(part for part in history.drawn_fragments + if part.message.view_key == rows[-1].view_key) + await history.reveal_message_head(latest, lambda: True) + history.show_fragment(latest) + await ui.until(lambda: ui.shows("row 7"), why="the newest message head") recorded = tuple(fragment.message.view_key for fragment in history.fragments) await ui.press("ctrl+t") await ui.until(lambda: isinstance(history.page_source.loader.style, MarkdownMessageStyle), why="the Markdown style") diff --git a/tests/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index db655fcb3..98d2fa14d 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -1,5 +1,7 @@ """Textual-facing: whether a channel message counts as read depends on which rows Textual painted, and a divider painted without its text is not enough.""" +import asyncio + from agent_comms.repo import Checkout from agent_comms.ui_model.unread import ExactUnread @@ -31,8 +33,14 @@ async def test_divider_without_body_leaves_the_message_unread(workspace): await ui.step() assert not window.follows_tail last = comms.messaging.send("peer", "#team", "LAST " + "body " * 250).message - await ui.until(lambda: text_rows_of(chat, last) is not None, - seconds=5, why="the last message laid out") + # Offscreen rows are deliberately lazy. Approach through the viewport's + # real input route until lookahead prepares the new message, stopping + # before its body can paint or supply a read mark. + async with asyncio.timeout(5): + while text_rows_of(chat, last) is None: + window.action_scroll_down() + await ui.step() + assert not ui.shows("LAST"), "Setup scrolled past the unread caption" assert not ui.shows("LAST") # Its caption is above its text: place that caption at the viewport's From 656786c49735a42c9a34925a987cf0088f251221 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 15:16:06 -0500 Subject: [PATCH 16/23] Let sidebar and row owners carry mobile presentation contracts --- src/toad/line_blocks.py | 5 +- src/toad/screens/session_view.py | 12 ++-- src/toad/sidebar_layout.py | 47 ++++++++----- src/toad/widgets/message_divider.py | 22 +++--- src/toad/widgets/session_thread_sidebar.py | 5 +- src/toad/widgets/side_bar.py | 82 +++++++++++++++------- src/toad/widgets/transcript_fragments.py | 3 +- src/toad/widgets/transcript_lines.py | 8 ++- tests/textual/test_wire_message_lines.py | 32 ++++++++- 9 files changed, 141 insertions(+), 75 deletions(-) diff --git a/src/toad/line_blocks.py b/src/toad/line_blocks.py index 7c97dbab0..c912f8b8e 100644 --- a/src/toad/line_blocks.py +++ b/src/toad/line_blocks.py @@ -304,9 +304,8 @@ def selection_text(self) -> str: return self.text @classmethod - def titled(cls, title: str, role: type[LineRole] = MutedRole, - link: LineLink | None = None) -> Self: - return cls((Span(title, role, link),)) + def titled(cls, title: str, role: type[LineRole] = MutedRole) -> Self: + return cls((Span(title, role),)) @dataclass(frozen=True) diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index 04a94593c..792500f60 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -28,9 +28,9 @@ class SessionView(DeclaredWidgetActions, VisibleReadMarking, CoreEventReceiver, DEFAULT_CSS = "SessionView { width: 1fr; height: 1fr; }" - def __init__(self, *args, **kwargs): - self._identity: SessionIdentity = NoThreadIdentity() - super().__init__(*args, **kwargs) + # The absent identity is an immutable value; concrete bindings replace it + # on the instance. Native construction stays with Vertical/Widget. + _identity: SessionIdentity = NoThreadIdentity() def close_core_events(self) -> None: """Retire this logical source's descendants before its tab entry disappears.""" @@ -172,9 +172,9 @@ class SidebarSessionView[SidebarT: ThreadSidebar](SessionView): the same resource after it has left that tree. """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._thread_sidebar_state = SidebarState() + @cached_property + def _thread_sidebar_state(self) -> SidebarState: + return SidebarState() @abstractmethod def make_thread_sidebar(self) -> SidebarT: ... diff --git a/src/toad/sidebar_layout.py b/src/toad/sidebar_layout.py index e2b2c6f09..b199336f4 100644 --- a/src/toad/sidebar_layout.py +++ b/src/toad/sidebar_layout.py @@ -10,6 +10,8 @@ from agent_comms.nominal.declared_family import DeclaredFamily from textual.css.types import DockEdge from textual.geometry import Offset, Region, Size, Spacing +from textual.widget import Widget +from textual.widgets import Static class SidebarEdge(DeclaredFamily, affix="SidebarEdge"): @@ -48,11 +50,11 @@ def gutter(cls, extent: int) -> Spacing: ... @classmethod @abstractmethod - def size_toggle(cls, toggle) -> None: ... + def size_toggle(cls, toggle: Widget) -> None: ... @classmethod @abstractmethod - def size_handle(cls, handle) -> None: ... + def size_handle(cls, handle: Widget) -> None: ... @classmethod @abstractmethod @@ -118,11 +120,11 @@ def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: span.extent, max(0, size.height - occupied.height)) @classmethod - def size_toggle(cls, toggle) -> None: + def size_toggle(cls, toggle: Widget) -> None: toggle.styles.width, toggle.styles.height = cls.collapsed_extent, "1fr" @classmethod - def size_handle(cls, handle) -> None: + def size_handle(cls, handle: Widget) -> None: handle.styles.width, handle.styles.height = 1, "1fr" handle.styles.pointer = "ew-resize" @@ -157,11 +159,11 @@ def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: return Region(0, cls.origin(size.height, span.offset, span.extent), size.width, span.extent) @classmethod - def size_toggle(cls, toggle) -> None: + def size_toggle(cls, toggle: Widget) -> None: toggle.styles.width, toggle.styles.height = "1fr", cls.collapsed_extent @classmethod - def size_handle(cls, handle) -> None: + def size_handle(cls, handle: Widget) -> None: handle.styles.width, handle.styles.height = "1fr", 1 handle.styles.pointer = "ns-resize" @@ -223,32 +225,35 @@ def tooltip(self, direction: type[SidebarEdge]) -> str: ... class BlockedSidebarShift(SidebarShift): available = False - def apply(self, layout, identity, direction): + def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: return False - def tooltip(self, direction): + def tooltip(self, direction: type[SidebarEdge]) -> str: return "Outside workspace edge" class SwapSidebarShift(SidebarShift): - def apply(self, layout, identity, direction): + def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: return layout.swap(identity) - def tooltip(self, direction): + def tooltip(self, direction: type[SidebarEdge]) -> str: return f"Swap with the sidebar toward {direction.declared_name}" class MoveSidebarShift(SidebarShift): - def apply(self, layout, identity, direction): + def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: return layout.move(identity, direction) - def tooltip(self, direction): + def tooltip(self, direction: type[SidebarEdge]) -> str: return f"Move sidebar to {direction.declared_name}" class SidebarPlacementAction(DeclaredFamily, affix="SidebarPlacementAction"): classes = "" - label: str + + @property + @abstractmethod + def label(self) -> str: ... @property def name(self) -> str: @@ -262,7 +267,7 @@ def offered(cls) -> Iterator[SidebarPlacementAction]: def apply(self, layout: SidebarLayout, identity: str) -> None: ... @abstractmethod - def present(self, button, placement: SidebarPlacement, + def present(self, button: Static, placement: SidebarPlacement, directions: dict[type[SidebarEdge], SidebarShift]) -> None: ... @@ -284,22 +289,26 @@ def offered(cls) -> Iterator[SidebarPlacementAction]: for edge in SidebarEdge.members_with(SidebarEdge): yield cls(edge) - def apply(self, layout, identity): + def apply(self, layout: SidebarLayout, identity: str) -> None: layout.shift(identity, self.direction) - def present(self, button, placement, directions): + def present(self, button: Static, placement: SidebarPlacement, + directions: dict[type[SidebarEdge], SidebarShift]) -> None: action = directions[self.direction] button.display = action.available button.tooltip = action.tooltip(self.direction) class FloatSidebarPlacementAction(SidebarPlacementAction): - label = "Float" + @property + def label(self) -> str: + return "Float" - def apply(self, layout, identity): + def apply(self, layout: SidebarLayout, identity: str) -> None: layout.float_mode(identity) - def present(self, button, placement, directions): + def present(self, button: Static, placement: SidebarPlacement, + directions: dict[type[SidebarEdge], SidebarShift]) -> None: button.update("Push" if placement.floating else "Float", layout=False) button.tooltip = ("Push conversation text" if placement.floating else "Float over conversation text") diff --git a/src/toad/widgets/message_divider.py b/src/toad/widgets/message_divider.py index 3ca44fe07..9f2b189d2 100644 --- a/src/toad/widgets/message_divider.py +++ b/src/toad/widgets/message_divider.py @@ -23,8 +23,10 @@ from toad.widgets.committed_presentation import CommitParticipant from toad.widgets.linked_rows import LinkedRows, MessageRowBlock from toad.widgets.transcript_lines import ( + BlockLines, LineTheme, - PreparedLines, + NoRows, + RowAcquisition, RichLineRenderer, link_place, ) @@ -39,7 +41,7 @@ def __init__(self, block: LineBlock) -> None: super().__init__() self.block = block self._link_place = link_place() - self._lines: tuple[LineTheme, PreparedLines] | None = None + self._lines: RowAcquisition = NoRows() @property def line_theme(self) -> LineTheme: @@ -47,17 +49,15 @@ def line_theme(self) -> LineTheme: def update_block(self, block: LineBlock) -> None: if block != self.block: - self.block, self._lines = block, None + self.block, self._lines = block, NoRows() self.refresh(layout=True) - def _drawn(self, theme: LineTheme, width: int) -> PreparedLines: - return PreparedLines(width, RichLineRenderer(theme, width).rows((self.block,))) - - def prepared_lines(self, width: int) -> PreparedLines: - theme = self.line_theme - if self._lines is None or self._lines[0] != theme or self._lines[1].width != width: - self._lines = theme, self._drawn(theme, width) - return self._lines[1] + def prepared_lines(self, width: int) -> BlockLines: + measured = self._lines.plain( + width, RichLineRenderer(self.line_theme, width), (self.block,) + ) + self._lines = measured + return measured.lines def render_line(self, y: int, paint: PaintState) -> Strip: width = self.size.width diff --git a/src/toad/widgets/session_thread_sidebar.py b/src/toad/widgets/session_thread_sidebar.py index f8e9ee9ba..4c8e54f57 100644 --- a/src/toad/widgets/session_thread_sidebar.py +++ b/src/toad/widgets/session_thread_sidebar.py @@ -67,11 +67,10 @@ async def evict(self, screen: "MainScreen") -> None: self.navigation.panel_scroll_y = self.panels_viewport.scroll_y self.capture_panels(screen) await self.panels_viewport.remove_children() - if controls := self.query_one_optional("#sidebar-controls"): - await controls.remove_children() + await self.layout_controls.remove_children() self.panels.clear() self._panels_loaded = self._panels_loading = False - self._presented_layout = None + self.invalidate_controls() self._panels_ready.set() async def panels_hydrated(self) -> None: diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 41af27110..5b2107787 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -1,5 +1,7 @@ from __future__ import annotations +from functools import cached_property + from textual._callback import invoke from metaclass_registry.mro_dispatch import handles @@ -69,11 +71,31 @@ def from_placement( ) +class BarDisplay: + """Whether native controls already show a requested sidebar projection.""" + + def matches(self, current: PresentedBar) -> bool: + return False + + def controls_changed(self, current: PresentedBar) -> bool: + return True + + +class UnpresentedBar(BarDisplay): + """No native projection has been applied, or its view was retired.""" + + @dataclass(frozen=True) -class PresentedBar: +class PresentedBar(BarDisplay): presentation: BarPresentation extent_percent: int + def matches(self, current: PresentedBar) -> bool: + return self == current + + def controls_changed(self, current: PresentedBar) -> bool: + return self.presentation != current.presentation + class SidebarPanelWidget(Widget): """Every sidebar panel accepts visibility changes; passive panels do nothing.""" @@ -653,6 +675,7 @@ def __init__( self.content_stack = SidebarContentStack() self.search_input = SidebarSearch(placeholder="Search", compact=True) self.panels_viewport: SidebarViewport = SidebarViewport() + self.layout_controls = containers.Vertical(id="sidebar-controls") self.hide = hide self.extent_slider = SidebarSlider( SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, @@ -662,7 +685,18 @@ def __init__( self._panels_loading = False self._panels_ready = asyncio.Event() self._presented_collapsed: bool | None = None - self._presented_layout: PresentedBar | None = None + self._presented_layout: BarDisplay = UnpresentedBar() + + def invalidate_controls(self) -> None: + self._presented_layout = UnpresentedBar() + + @cached_property + def toggle_control(self) -> SideBarToggle: + return SideBarToggle(self.collapsed, edge=self.placement.side) + + @cached_property + def resize_handle(self) -> SidebarResizeHandle: + return SidebarResizeHandle(self.placement.side) @property def placement(self) -> SidebarPlacement: @@ -729,12 +763,10 @@ async def _hydrate_panels(self) -> None: await self.panels_viewport.mount_all(self._compose_panels()) if not self.presentation_visible: return - controls = self.query_one_optional("#sidebar-controls") - if controls is not None: - await controls.mount_all(self._compose_controls()) + await self.layout_controls.mount_all(self._compose_controls()) if self.presentation_visible: self._panels_loaded = True - self._presented_layout = None + self.invalidate_controls() await self.panels_hydrated() self.restore_navigation() finally: @@ -750,14 +782,14 @@ def on_unmount(self) -> None: self._panels_ready.set() def compose(self) -> ComposeResult: - yield SideBarToggle(self.collapsed, edge=self.placement.side) - yield SidebarResizeHandle(self.placement.side) + yield self.toggle_control + yield self.resize_handle with self.content_stack: yield self.search_input with self.panels_viewport: if self._panels_loaded: yield from self._compose_panels() - with containers.Vertical(id="sidebar-controls"): + with self.layout_controls: if self._panels_loaded: yield from self._compose_controls() @@ -795,7 +827,7 @@ def on_show(self) -> None: def present_controls(self) -> bool: if not self.presentation_visible: - self._presented_layout = None + self.invalidate_controls() return False placement = self.placement directions = self.app.sidebar_layout.directions(self.id) @@ -806,25 +838,23 @@ def present_controls(self) -> bool: extent_percent=placement.extent_percent, ) previous = self._presented_layout - if current == previous: + if previous.matches(current): return False self._presented_layout = current - controls = self.query_one_optional("#sidebar-controls") if self._panels_loaded else None slider = self.extent_slider - if previous is None or previous.presentation != current.presentation: + if previous.controls_changed(current): edge = placement.side self.content_stack.styles.padding = edge.content_padding() - if toggle := self.query_one_optional(SideBarToggle): - toggle.set_collapsed(self.collapsed, edge) - if handle := self.query_one_optional(SidebarResizeHandle): - edge.size_handle(handle) - handle.styles.dock = edge.opposite().dock - handle.display = not self.collapsed - if controls is not None: + self.toggle_control.set_collapsed(self.collapsed, edge) + handle = self.resize_handle + edge.size_handle(handle) + handle.styles.dock = edge.opposite().dock + handle.display = not self.collapsed + if self._panels_loaded: slider.reversed = edge.sign < 0 slider.set_range(SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, placement.extent_percent) - for button in controls.query(SidebarAction): + for button in self.layout_controls.query(SidebarAction): button.action.present(button, placement, directions) if slider.value != placement.extent_percent: slider.value = placement.extent_percent @@ -873,13 +903,11 @@ def watch_collapsed(self, collapsed: bool) -> None: # One content flow hides without retiring its retained children. self.content_stack.display = not collapsed self.tooltip = "Expand sidebar" if collapsed else None - toggle = self.query_one_optional(SideBarToggle) - if toggle is not None: - toggle.set_collapsed(collapsed, self.placement.side) - toggle.refresh_paint() + self.toggle_control.set_collapsed(collapsed, self.placement.side) + self.toggle_control.refresh_paint() for panel in self.panels: panel.widget.sidebar_visibility_changed() - self._presented_layout = None + self.invalidate_controls() self.app.workspace_chrome.refresh_layout() def render(self) -> str: @@ -904,7 +932,7 @@ def on_click(self, event: Click) -> None: @on(events.Leave) def gutter_hover_changed(self) -> None: if self.presentation_visible: - self.query_one(SideBarToggle).refresh_paint() + self.toggle_control.refresh_paint() def toggle(self, *, focus: bool = True) -> None: collapsed = not self.collapsed diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index c478fa007..b0b684091 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -39,6 +39,7 @@ LineRole, MarkdownBody, MutedRole, + Span, SpanText, Spans, Summary, @@ -222,7 +223,7 @@ class Shown: def summary(self, title: str, role: type[LineRole], link: LineLink) -> SpanText: """The disclosure retains the source occurrence supplied by its fragment.""" - return self.summary_type.titled(("▼ " if self.details_open else "▶ ") + title, role, link) + return self.summary_type((Span(("▼ " if self.details_open else "▶ ") + title, role, link),)) def toggled(self) -> "Shown": return replace(self, details_open=not self.details_open) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index 9d11a4ff5..b70f72ba2 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -457,7 +457,7 @@ def rich_text(self, block: RichTextBody) -> None: @handles(Divider) def divider(self, block: Divider) -> None: - groups = (block.spans,) + groups: tuple[tuple[Span, ...], ...] = (block.spans,) if block.timestamp is not None: timestamp = MessageClock.recorded(block.timestamp).display()[0] timed = (*block.spans, Span(f" · {timestamp}")) @@ -683,8 +683,10 @@ def for_view(self, width: int, theme: LineTheme): def retains(self, lines: BlockLines, theme: LineTheme) -> bool: return self.source.theme is theme and self.lines.preparation is lines.preparation - def plain(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]): - if self.source.theme is renderer.theme: + def plain(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]) -> MeasuredRows: + # Synchronous rows depend on style values, including a divider's fade. + # Async publication/retention still requires the captured theme identity. + if self.source.theme == renderer.theme: self.lines = self.lines.rewrap(width) return self return super().plain(width, renderer, blocks) diff --git a/tests/textual/test_wire_message_lines.py b/tests/textual/test_wire_message_lines.py index 7fb066138..d0950865f 100644 --- a/tests/textual/test_wire_message_lines.py +++ b/tests/textual/test_wire_message_lines.py @@ -1,15 +1,19 @@ """Textual-facing: a wire message's lines wrap at the full width as Rich draws them, and its names stay clickable at every width.""" +from dataclasses import replace + +from rich.style import Style + from agent_comms.message_page import MessagePageRequest from agent_comms.display_references import ProjectReferences from agent_comms.messages import InfoKind, Message from textual.app import App -from toad.line_blocks import OpenTarget +from toad.line_blocks import Divider, OpenTarget, Span, UserRole from toad.markdown_links import LinkContext from toad.notification_status import CheckedNotifications from toad.textual_colours import ThemedApp -from toad.widgets.transcript_lines import LinePreparation, PlainLines, RichLineRenderer, TranscriptLinesRenderTask +from toad.widgets.transcript_lines import LinePreparation, NoRows, PlainLines, RichLineRenderer, TranscriptLinesRenderTask from toad.message_rows import IrcMessageStyle, MarkdownMessageStyle from toad.widgets.transcript_fragments import MessageShown from toad.wire_history import WireFragment @@ -60,6 +64,30 @@ def test_rows_fit_the_width_wrap_on_words_and_keep_drawn_names_clickable_at_ever assert actual == expected, (width, text, actual, expected) +def test_divider_acquisition_reuses_equal_fades_rewraps_and_repaints_changed_styles() -> None: + current_theme = theme() + link = OpenTarget("receiver-with-long-name") + block = Divider((Span("Sender 界 → receiver-with-long-name", UserRole, link),), UserRole) + acquired = NoRows().plain(36, RichLineRenderer(current_theme, 36), (block,)) + first = acquired.lines + assert tuple(first) == RichLineRenderer(current_theme, 36).rows((block,)) + assert link.click in clicks(first) + + equal_fade = replace(current_theme, styles=dict(current_theme.styles)) + assert acquired.plain(36, RichLineRenderer(equal_fade, 36), (block,)) is acquired + assert acquired.lines is first + assert acquired.plain(80, RichLineRenderer(equal_fade, 80), (block,)) is acquired + assert acquired.lines is not first + assert tuple(acquired.lines) == RichLineRenderer(equal_fade, 80).rows((block,)) + + changed_fade = replace(current_theme, styles={**current_theme.styles, UserRole: Style(color="red")}) + changed = acquired.plain(80, RichLineRenderer(changed_fade, 80), (block,)) + assert changed is not acquired + assert tuple(changed.lines) == RichLineRenderer(changed_fade, 80).rows((block,)) + assert tuple(changed.lines) != tuple(acquired.lines) + assert link.click in clicks(changed.lines) + + def test_opening_notification_details_draws_more_rows_than_the_collapsed_message(): status = CheckedNotifications.from_notifications(()) for style in (IrcMessageStyle(), MarkdownMessageStyle()): From 07566277cc935160184bc5801b83fb28b2fd552b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 15:46:08 -0500 Subject: [PATCH 17/23] Make concrete selection return contracts truthful --- src/toad/widgets/line_markdown.py | 2 +- src/toad/widgets/linked_rows.py | 2 +- src/toad/widgets/message_divider.py | 2 +- src/toad/widgets/terminal.py | 4 ++-- src/toad/workspace_chrome.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/toad/widgets/line_markdown.py b/src/toad/widgets/line_markdown.py index 84bb2a1e6..3f130867b 100644 --- a/src/toad/widgets/line_markdown.py +++ b/src/toad/widgets/line_markdown.py @@ -191,5 +191,5 @@ def render_line(self, y: int, paint: PaintState) -> Strip: # Prepared rows span exactly this width (Lines.strips_at). return self.on_background(strips[y], paint, y) - def get_selection(self, selection: Selection) -> tuple[str, str] | None: + def get_selection(self, selection: Selection) -> tuple[str, str]: return selection.extract(self.text), "\n" diff --git a/src/toad/widgets/linked_rows.py b/src/toad/widgets/linked_rows.py index e45356d54..f031a3ed5 100644 --- a/src/toad/widgets/linked_rows.py +++ b/src/toad/widgets/linked_rows.py @@ -151,7 +151,7 @@ def on_mount(self) -> None: def line_sets(self) -> tuple[Lines, ...]: raise NotImplementedError - def get_selection(self, selection: Selection) -> tuple[str, str] | None: + def get_selection(self, selection: Selection) -> tuple[str, str]: rows = "\n".join("".join(segment.text for segment in row).rstrip() for lines in self.line_sets for row in lines) return selection.extract(rows), "\n" diff --git a/src/toad/widgets/message_divider.py b/src/toad/widgets/message_divider.py index 9f2b189d2..55dc12142 100644 --- a/src/toad/widgets/message_divider.py +++ b/src/toad/widgets/message_divider.py @@ -76,7 +76,7 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int class ConversationLineBlock(BlockContent, LineBlockRow): """A line block in the conversation: navigable and selectable like any block.""" - def get_selection(self, selection: Selection) -> tuple[str, str] | None: + def get_selection(self, selection: Selection) -> tuple[str, str]: return selection.extract(self.block.selection_text), "\n" diff --git a/src/toad/widgets/terminal.py b/src/toad/widgets/terminal.py index 537fa941e..0d54b7071 100644 --- a/src/toad/widgets/terminal.py +++ b/src/toad/widgets/terminal.py @@ -184,14 +184,14 @@ def allow_focus(self) -> bool: """Prohibit focus when the terminal is finalized and couldn't accept input.""" return not self.is_finalized - def get_selection(self, selection: Selection) -> tuple[str, str] | None: + def get_selection(self, selection: Selection) -> tuple[str, str]: """Get the text under the selection. Args: selection: Selection information. Returns: - Tuple of extracted text and ending (typically "\n" or " "), or `None` if no text could be extracted. + Tuple of extracted text and ending (typically "\n" or " "). """ text = "\n".join( line_record.content.plain for line_record in self.state.buffer.lines diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 5c916bb4f..5c7fe060e 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from textual.app import ComposeResult from textual.containers import Horizontal -from textual.geometry import Offset, Region, Size, Spacing +from textual.geometry import Offset, Size, Spacing from textual.layout import ArrangeResult, WidgetPlacement from textual.layouts.vertical import VerticalLayout from textual.widget import Widget From 7c125f2004c7caa410ee46723de997deacd23f2e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 16:04:38 -0500 Subject: [PATCH 18/23] Put native sidebar geometry cases in the Textual layer --- tests/{model => textual}/test_sidebar_layout_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{model => textual}/test_sidebar_layout_policy.py (98%) diff --git a/tests/model/test_sidebar_layout_policy.py b/tests/textual/test_sidebar_layout_policy.py similarity index 98% rename from tests/model/test_sidebar_layout_policy.py rename to tests/textual/test_sidebar_layout_policy.py index e35bbd1b8..e26092eca 100644 --- a/tests/model/test_sidebar_layout_policy.py +++ b/tests/textual/test_sidebar_layout_policy.py @@ -1,4 +1,4 @@ -"""Placement preserves both sidebars, peer order, axis bounds and conversation clearance.""" +"""Textual-facing: native sidebar geometry preserves peer order and conversation clearance.""" from itertools import product From d4c887bbbf7e6aa4816ce79f741a265522a0bee5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 16:52:03 -0500 Subject: [PATCH 19/23] Derive sidebar placement roles instead of using DOM identity --- src/toad/sidebar_layout.py | 68 ++++++++++++++------- src/toad/widgets/channels_sidebar.py | 3 +- src/toad/widgets/side_bar.py | 23 ++++--- src/toad/workspace_chrome.py | 15 +++-- tests/textual/test_sidebar_direction.py | 14 ++--- tests/textual/test_sidebar_drag_resize.py | 16 ++--- tests/textual/test_sidebar_layout_policy.py | 65 ++++++++++---------- tests/textual/test_sidebar_pointer_focus.py | 44 ++++++------- 8 files changed, 139 insertions(+), 109 deletions(-) diff --git a/src/toad/sidebar_layout.py b/src/toad/sidebar_layout.py index b199336f4..1028ed326 100644 --- a/src/toad/sidebar_layout.py +++ b/src/toad/sidebar_layout.py @@ -5,7 +5,7 @@ from abc import abstractmethod from collections.abc import Iterator from dataclasses import dataclass, replace -from typing import ClassVar +from typing import ClassVar, Self from agent_comms.nominal.declared_family import DeclaredFamily from textual.css.types import DockEdge @@ -216,7 +216,7 @@ class SidebarShift(DeclaredFamily, affix="SidebarShift"): available = True @abstractmethod - def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: ... + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: ... @abstractmethod def tooltip(self, direction: type[SidebarEdge]) -> str: ... @@ -225,7 +225,7 @@ def tooltip(self, direction: type[SidebarEdge]) -> str: ... class BlockedSidebarShift(SidebarShift): available = False - def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: return False def tooltip(self, direction: type[SidebarEdge]) -> str: @@ -233,7 +233,7 @@ def tooltip(self, direction: type[SidebarEdge]) -> str: class SwapSidebarShift(SidebarShift): - def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: return layout.swap(identity) def tooltip(self, direction: type[SidebarEdge]) -> str: @@ -241,7 +241,7 @@ def tooltip(self, direction: type[SidebarEdge]) -> str: class MoveSidebarShift(SidebarShift): - def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: return layout.move(identity, direction) def tooltip(self, direction: type[SidebarEdge]) -> str: @@ -264,7 +264,7 @@ def offered(cls) -> Iterator[SidebarPlacementAction]: yield cls() @abstractmethod - def apply(self, layout: SidebarLayout, identity: str) -> None: ... + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement]) -> None: ... @abstractmethod def present(self, button: Static, placement: SidebarPlacement, @@ -289,7 +289,7 @@ def offered(cls) -> Iterator[SidebarPlacementAction]: for edge in SidebarEdge.members_with(SidebarEdge): yield cls(edge) - def apply(self, layout: SidebarLayout, identity: str) -> None: + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement]) -> None: layout.shift(identity, self.direction) def present(self, button: Static, placement: SidebarPlacement, @@ -304,7 +304,7 @@ class FloatSidebarPlacementAction(SidebarPlacementAction): def label(self) -> str: return "Float" - def apply(self, layout: SidebarLayout, identity: str) -> None: + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement]) -> None: layout.float_mode(identity) def present(self, button: Static, placement: SidebarPlacement, @@ -315,12 +315,35 @@ def present(self, button: Static, placement: SidebarPlacement, @dataclass(frozen=True, slots=True) -class SidebarPlacement: +class SidebarPlacement(DeclaredFamily, affix="SidebarPlacement"): + """A nominal workspace role owns its initial policy and native spelling.""" + + default_side: ClassVar[type[SidebarEdge]] + default_extent_percent: ClassVar[int] + side: type[SidebarEdge] order: int extent_percent: int floating: bool = False + @classmethod + def initial(cls) -> Self: + return cls(cls.default_side, 0, cls.default_extent_percent) + + @classmethod + def native_id(cls) -> str: + return f"{cls.declared_name}-sidebar" + + +class ChannelsSidebarPlacement(SidebarPlacement): + default_side = LeftSidebarEdge + default_extent_percent = 40 + + +class ThreadSidebarPlacement(SidebarPlacement): + default_side = RightSidebarEdge + default_extent_percent = 34 + @dataclass(frozen=True, slots=True) class SidebarGeometry: @@ -330,7 +353,7 @@ class SidebarGeometry: @dataclass(frozen=True, slots=True) class ResolvedSidebarLayout: - bars: dict[str, Region] + bars: dict[type[SidebarPlacement], Region] gutter: Spacing @@ -341,15 +364,14 @@ class SidebarLayout: maximum_percent = 50 def __init__(self) -> None: - self.placements: dict[str, SidebarPlacement] = { - "channels-sidebar": SidebarPlacement(LeftSidebarEdge, 0, 40), - "thread-sidebar": SidebarPlacement(RightSidebarEdge, 0, 34), + self.placements: dict[type[SidebarPlacement], SidebarPlacement] = { + role: role.initial() for role in SidebarPlacement.members_with(SidebarPlacement) } - def get(self, identity: str) -> SidebarPlacement: + def get(self, identity: type[SidebarPlacement]) -> SidebarPlacement: return self.placements[identity] - def move(self, identity: str, side: type[SidebarEdge]) -> bool: + def move(self, identity: type[SidebarPlacement], side: type[SidebarEdge]) -> bool: current = self.get(identity) if current.side is side: return False @@ -359,7 +381,7 @@ def move(self, identity: str, side: type[SidebarEdge]) -> bool: self._renumber(current.side) return True - def swap(self, identity: str) -> bool: + def swap(self, identity: type[SidebarPlacement]) -> bool: current = self.get(identity) peers = [key for key, placement in self.placements.items() if key != identity and placement.side is current.side] @@ -370,7 +392,7 @@ def swap(self, identity: str) -> bool: self.placements[other] = replace(self.placements[other], order=current.order) return True - def directions(self, identity: str) -> dict[type[SidebarEdge], SidebarShift]: + def directions(self, identity: type[SidebarPlacement]) -> dict[type[SidebarEdge], SidebarShift]: """Each spatial arrow swaps a neighbor, crosses the center or stops at a wall.""" current = self.get(identity) actions: dict[type[SidebarEdge], SidebarShift] = {} @@ -389,10 +411,10 @@ def directions(self, identity: str) -> dict[type[SidebarEdge], SidebarShift]: actions[direction] = BlockedSidebarShift() return actions - def shift(self, identity: str, direction: type[SidebarEdge]) -> bool: + def shift(self, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: return self.directions(identity)[direction].apply(self, identity, direction) - def resize(self, identity: str, percentage: int) -> bool: + def resize(self, identity: type[SidebarPlacement], percentage: int) -> bool: current = self.get(identity) percentage = max(self.minimum_percent, min(self.maximum_percent, percentage)) if current.extent_percent == percentage: @@ -400,18 +422,18 @@ def resize(self, identity: str, percentage: int) -> bool: self.placements[identity] = replace(current, extent_percent=percentage) return True - def float_mode(self, identity: str) -> bool: + def float_mode(self, identity: type[SidebarPlacement]) -> bool: current = self.get(identity) self.placements[identity] = replace(current, floating=not current.floating) return self.placements[identity].floating - def resolve(self, viewport: Size, collapsed: dict[str, bool]) -> ResolvedSidebarLayout: + def resolve(self, viewport: Size, collapsed: dict[type[SidebarPlacement], bool]) -> ResolvedSidebarLayout: """Pack each axis once; top/bottom surfaces own the corners. Float changes only conversation clearance. A pushed inner peer clears its floating outer peer too; collapsed handles always reserve space. """ - spans: dict[str, SidebarGeometry] = {} + spans: dict[type[SidebarPlacement], SidebarGeometry] = {} occupied = gutter = Spacing() edges = SidebarEdge.members_with(SidebarEdge) for axis in dict.fromkeys(edge.axis() for edge in edges): @@ -460,7 +482,7 @@ def _renumber(self, side: type[SidebarEdge]) -> None: for index, key in enumerate(names): self.placements[key] = replace(self.placements[key], order=index) - def ordered(self) -> tuple[str, ...]: + def ordered(self) -> tuple[type[SidebarPlacement], ...]: edges = SidebarEdge.members_with(SidebarEdge) return tuple(sorted(self.placements, key=lambda key: (edges.index(self.get(key).side), diff --git a/src/toad/widgets/channels_sidebar.py b/src/toad/widgets/channels_sidebar.py index 170ad0eb8..2f4b3f480 100644 --- a/src/toad/widgets/channels_sidebar.py +++ b/src/toad/widgets/channels_sidebar.py @@ -1,5 +1,6 @@ """The shared Channels view, including during a destination's loading frame.""" +from toad.sidebar_layout import ChannelsSidebarPlacement from toad.widgets.comms_sidebar import CommsSidebar from toad.widgets.session_sort import ChannelListSort from toad.widgets.side_bar import SideBar @@ -16,7 +17,7 @@ def __init__(self, *, observe: bool = True, defer_mount: bool = False) -> None: flex=True, header_control=ChannelListSort(), ), - id="channels-sidebar", + placement_identity=ChannelsSidebarPlacement, defer_mount=defer_mount, ) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 5b2107787..b60eb39ef 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -38,6 +38,7 @@ SidebarPlacement, SidebarPlacementAction, SidebarShift, + ThreadSidebarPlacement, ) from toad.widgets.sidebar_viewport import SidebarHeader, SidebarViewport from toad.app_node import ToadNode @@ -660,17 +661,23 @@ class Panel: id: str | None = None header_control: Widget | None = None + @property + def placement_identity(self) -> type[SidebarPlacement]: + return self._placement_identity + def __init__( self, *panels: Panel, + placement_identity: type[SidebarPlacement], name: str | None = None, - id: str | None = None, classes: str | None = None, disabled: bool = False, hide: bool = False, defer_mount: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self._placement_identity = placement_identity + super().__init__(name=name, id=placement_identity.native_id(), + classes=classes, disabled=disabled) self.panels: list[SideBar.Panel] = [*panels] self.content_stack = SidebarContentStack() self.search_input = SidebarSearch(placeholder="Search", compact=True) @@ -700,7 +707,7 @@ def resize_handle(self) -> SidebarResizeHandle: @property def placement(self) -> SidebarPlacement: - return self.app.sidebar_layout.get(self.id) + return self.app.sidebar_layout.get(self.placement_identity) @property def panels_loaded(self) -> bool: @@ -830,7 +837,7 @@ def present_controls(self) -> bool: self.invalidate_controls() return False placement = self.placement - directions = self.app.sidebar_layout.directions(self.id) + directions = self.app.sidebar_layout.directions(self.placement_identity) current = PresentedBar( presentation=BarPresentation.from_placement( placement, collapsed=self.collapsed, directions=directions @@ -862,7 +869,7 @@ def present_controls(self) -> bool: return True def resize_to(self, percentage: int) -> None: - if self.app.sidebar_layout.resize(self.id, percentage): + if self.app.sidebar_layout.resize(self.placement_identity, percentage): self.app.workspace_chrome.refresh_layout() @on(SidebarSlider.Changed) @@ -873,10 +880,8 @@ def on_sidebar_slider(self, event: SidebarSlider.Changed) -> None: @on(SidebarAction.Pressed) def on_sidebar_action(self, event: SidebarAction.Pressed) -> None: event.stop() - if self.id is None: - return app = self.app - event.action.apply(app.sidebar_layout, self.id) + event.action.apply(app.sidebar_layout, self.placement_identity) app.workspace_chrome.refresh_layout() def restore_navigation(self) -> bool: @@ -965,7 +970,7 @@ def dismiss_control(self) -> None: class ThreadSidebar(SideBar): def __init__(self, *panels, state: SidebarState, **kwargs): self.state = state - super().__init__(*panels, id="thread-sidebar", hide=True, **kwargs) + super().__init__(*panels, placement_identity=ThreadSidebarPlacement, hide=True, **kwargs) self.collapsed = self.hide @property diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 5c7fe060e..452e23dc8 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -8,6 +8,7 @@ from textual.layouts.vertical import VerticalLayout from textual.widget import Widget from toad.widgets.channels_sidebar import ChannelsSidebar +from toad.widgets.side_bar import SideBar from toad.widgets.footer import ToadFooter from toad.widgets.switch_toast import SwitchToast from ui_theme.scheme import HeaderToneColour @@ -43,16 +44,14 @@ def __init__(self, app: "ToadApp") -> None: self.switch_toast = SwitchToast() self.native = NativeSessionSurface(app) - def presented_sidebars(self): + def presented_sidebars(self) -> tuple[SideBar, ...]: source = self.app.workspace_sessions.source - layout = self.app.sidebar_layout return tuple(bar for bar in (self.channels, *source.sidebars()) - if bar.presentation_visible and bar.id in layout.placements) + if bar.presentation_visible) def toggle_sidebar(self, side: type[SidebarEdge]) -> None: - layout = self.app.sidebar_layout bars = tuple(bar for bar in self.presented_sidebars() - if layout.get(bar.id).side == side) + if bar.placement.side is side) collapsed = any(not bar.collapsed for bar in bars) for bar in bars: if bar.collapsed != collapsed: @@ -81,7 +80,7 @@ def arrange( """Place chrome and conversation directly in the native layout pass.""" layout = self.app.sidebar_layout bars = self.presented_sidebars() - resolved = layout.resolve(size, {bar.id: bar.collapsed for bar in bars}) + resolved = layout.resolve(size, {bar.placement_identity: bar.collapsed for bar in bars}) # The outer host stays full width. Its session's own native layout # clears both gutters, including the shared channel surface. nested = parent is self.channels.parent and any( @@ -103,14 +102,14 @@ def arrange( order = {identity: index for index, identity in enumerate(layout.ordered())} for bar in bars: if bar in children: - geometry = resolved.bars[bar.id] + geometry = resolved.bars[bar.placement_identity] placements.append( WidgetPlacement( geometry.reset_offset, geometry.offset, Spacing(), bar, - order[bar.id], + order[bar.placement_identity], overlay=True, absolute=True, ) diff --git a/tests/textual/test_sidebar_direction.py b/tests/textual/test_sidebar_direction.py index 2905b7b36..26734272e 100644 --- a/tests/textual/test_sidebar_direction.py +++ b/tests/textual/test_sidebar_direction.py @@ -10,16 +10,16 @@ async def test_arrow_clicks_follow_spatial_neighbours(workspace, side): async with workspace.app(size=(120, 60)) as ui: app, pilot = ui.textual - channels = app.screen.query_one("#channels-sidebar", SideBar) - thread = app.selected_session.query_one("#thread-sidebar", SideBar) + channels = app.workspace_chrome.channels + thread = app.selected_session.thread_sidebar channels.reveal() thread.reveal() # Vacate the destination before establishing the requested outer/inner # order. Moving an existing occupant to its own edge is a no-op. - app.sidebar_layout.move(channels.id, side.opposite()) - app.sidebar_layout.move(thread.id, side.opposite()) - app.sidebar_layout.move(channels.id, side) - app.sidebar_layout.move(thread.id, side) + app.sidebar_layout.move(channels.placement_identity, side.opposite()) + app.sidebar_layout.move(thread.placement_identity, side.opposite()) + app.sidebar_layout.move(channels.placement_identity, side) + app.sidebar_layout.move(thread.placement_identity, side) app.workspace_chrome.refresh_layout() await ui.step() @@ -29,7 +29,7 @@ async def click(bar: SideBar, edge) -> None: await ui.step() def check_controls(bar: SideBar) -> None: - for edge, shift in app.sidebar_layout.directions(bar.id).items(): + for edge, shift in app.sidebar_layout.directions(bar.placement_identity).items(): button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) assert button.display == shift.available diff --git a/tests/textual/test_sidebar_drag_resize.py b/tests/textual/test_sidebar_drag_resize.py index 4f226e924..6930371e1 100644 --- a/tests/textual/test_sidebar_drag_resize.py +++ b/tests/textual/test_sidebar_drag_resize.py @@ -8,7 +8,7 @@ from textual.keys import RightKey from toad.sidebar_layout import SidebarEdge, WidthSidebarEdge from toad.widgets.side_bar import ( - SideBar, SideBarToggle, SidebarAction, SidebarResizeHandle, SidebarSlider, TabHistoryControls, + SideBarToggle, SidebarAction, SidebarResizeHandle, SidebarSlider, TabHistoryControls, ) @@ -16,9 +16,9 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, side): async with workspace.app(size=(120, 40)) as ui: app, pilot = ui.textual - bar = app.screen.query_one("#channels-sidebar", SideBar) - app.sidebar_layout.move("channels-sidebar", side) - app.sidebar_layout.resize("channels-sidebar", 30) + bar = app.workspace_chrome.channels + app.sidebar_layout.move(bar.placement_identity, side) + app.sidebar_layout.resize(bar.placement_identity, 30) app.workspace_chrome.refresh_layout() await ui.step() slider = bar.extent_slider @@ -54,7 +54,7 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s await pilot.mouse_up(offset=(start + direction * 12, y)) await ui.step() assert bar.size.width == old_width + 12, (side, bar.size, old_width) - assert slider.value == app.sidebar_layout.get(bar.id).extent_percent == 40 + assert slider.value == bar.placement.extent_percent == 40 assert not handle._dragging and app.mouse_captured is None # The physical arrow keys and slider endpoints mirror as well. @@ -101,9 +101,9 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s async def test_zero_motion_resize_keeps_requested_percentage_on_every_edge(workspace, side): async with workspace.app(size=(51, 27)) as ui: app, pilot = ui.textual - bar = app.screen.query_one("#channels-sidebar", SideBar) - app.sidebar_layout.move(bar.id, side) - app.sidebar_layout.resize(bar.id, 40) + bar = app.workspace_chrome.channels + app.sidebar_layout.move(bar.placement_identity, side) + app.sidebar_layout.resize(bar.placement_identity, 40) bar.reveal() app.workspace_chrome.refresh_layout() await ui.step() diff --git a/tests/textual/test_sidebar_layout_policy.py b/tests/textual/test_sidebar_layout_policy.py index e26092eca..6ea5c881e 100644 --- a/tests/textual/test_sidebar_layout_policy.py +++ b/tests/textual/test_sidebar_layout_policy.py @@ -5,7 +5,10 @@ import pytest from textual.geometry import Size -from toad.sidebar_layout import LeftSidebarEdge, RightSidebarEdge, SidebarEdge, SidebarLayout +from toad.sidebar_layout import ( + ChannelsSidebarPlacement, LeftSidebarEdge, RightSidebarEdge, + SidebarEdge, SidebarLayout, ThreadSidebarPlacement, +) EDGES = SidebarEdge.members_with(SidebarEdge) @@ -13,31 +16,31 @@ def test_moving_swapping_and_resizing_keep_both_sidebars_and_clamp_extent(): layout = SidebarLayout() - assert layout.ordered() == ("channels-sidebar", "thread-sidebar") - assert layout.move("channels-sidebar", RightSidebarEdge) - assert layout.get("thread-sidebar").order == 0 - assert layout.get("channels-sidebar").order == 1 - assert layout.swap("channels-sidebar") - assert layout.get("channels-sidebar").order == 0 - assert layout.get("thread-sidebar").order == 1 - assert layout.ordered() == ("thread-sidebar", "channels-sidebar") - assert layout.move("thread-sidebar", LeftSidebarEdge) - assert layout.ordered() == ("thread-sidebar", "channels-sidebar") - assert layout.get("thread-sidebar").order == 0 - assert layout.resize("channels-sidebar", 70) - assert layout.get("channels-sidebar").extent_percent == layout.maximum_percent - assert layout.resize("channels-sidebar", 1) - assert layout.get("channels-sidebar").extent_percent == layout.minimum_percent - assert layout.float_mode("thread-sidebar") is True - assert layout.float_mode("thread-sidebar") is False - assert set(layout.ordered()) == {"thread-sidebar", "channels-sidebar"} + assert layout.ordered() == (ChannelsSidebarPlacement, ThreadSidebarPlacement) + assert layout.move(ChannelsSidebarPlacement, RightSidebarEdge) + assert layout.get(ThreadSidebarPlacement).order == 0 + assert layout.get(ChannelsSidebarPlacement).order == 1 + assert layout.swap(ChannelsSidebarPlacement) + assert layout.get(ChannelsSidebarPlacement).order == 0 + assert layout.get(ThreadSidebarPlacement).order == 1 + assert layout.ordered() == (ThreadSidebarPlacement, ChannelsSidebarPlacement) + assert layout.move(ThreadSidebarPlacement, LeftSidebarEdge) + assert layout.ordered() == (ThreadSidebarPlacement, ChannelsSidebarPlacement) + assert layout.get(ThreadSidebarPlacement).order == 0 + assert layout.resize(ChannelsSidebarPlacement, 70) + assert layout.get(ChannelsSidebarPlacement).extent_percent == layout.maximum_percent + assert layout.resize(ChannelsSidebarPlacement, 1) + assert layout.get(ChannelsSidebarPlacement).extent_percent == layout.minimum_percent + assert layout.float_mode(ThreadSidebarPlacement) is True + assert layout.float_mode(ThreadSidebarPlacement) is False + assert set(layout.ordered()) == {ThreadSidebarPlacement, ChannelsSidebarPlacement} @pytest.mark.parametrize("side", EDGES) def test_two_sidebars_on_one_edge_swap_inward_then_cross(side): pair = SidebarLayout() - pair.move("channels-sidebar", side) - pair.move("thread-sidebar", side) + pair.move(ChannelsSidebarPlacement, side) + pair.move(ThreadSidebarPlacement, side) outer, inner = sorted(pair.placements, key=lambda key: pair.get(key).order) inward = side.opposite() before = dict(pair.placements) @@ -55,9 +58,9 @@ def test_two_sidebars_on_one_edge_swap_inward_then_cross(side): ) def test_float_keeps_order_and_swap_keeps_extents(side, viewport, hide_channels, hide_thread): pair = SidebarLayout() - pair.move("channels-sidebar", side) - pair.move("thread-sidebar", side) - collapsed = {"channels-sidebar": hide_channels, "thread-sidebar": hide_thread} + pair.move(ChannelsSidebarPlacement, side) + pair.move(ThreadSidebarPlacement, side) + collapsed = {ChannelsSidebarPlacement: hide_channels, ThreadSidebarPlacement: hide_thread} before = pair.resolve(viewport, collapsed) order = pair.ordered() for name in order: @@ -71,7 +74,7 @@ def test_float_keeps_order_and_swap_keeps_extents(side, viewport, hide_channels, for name, hidden in collapsed.items(): if hidden: assert side.extent(before.bars[name].size) == side.collapsed_extent - pair.swap("channels-sidebar") + pair.swap(ChannelsSidebarPlacement) after = pair.resolve(viewport, dict(reversed(tuple(collapsed.items())))) assert {key: side.extent(bar.size) for key, bar in after.bars.items()} == { key: side.extent(bar.size) for key, bar in before.bars.items() @@ -81,10 +84,10 @@ def test_float_keeps_order_and_swap_keeps_extents(side, viewport, hide_channels, @pytest.mark.parametrize("side, viewport", list(product(EDGES, (Size(51, 27), Size(36, 18))))) def test_shrinking_preserves_each_axis_minimum_when_both_controls_fit(side, viewport): layout = SidebarLayout() - layout.move("channels-sidebar", side) - layout.move("thread-sidebar", side.opposite()) - layout.resize("channels-sidebar", layout.maximum_percent) - layout.resize("thread-sidebar", layout.minimum_percent) + layout.move(ChannelsSidebarPlacement, side) + layout.move(ThreadSidebarPlacement, side.opposite()) + layout.resize(ChannelsSidebarPlacement, layout.maximum_percent) + layout.resize(ThreadSidebarPlacement, layout.minimum_percent) resolved = layout.resolve(viewport, {key: False for key in layout.placements}) assert all(side.extent(bar.size) >= side.minimum for bar in resolved.bars.values()) @@ -92,8 +95,8 @@ def test_shrinking_preserves_each_axis_minimum_when_both_controls_fit(side, view @pytest.mark.parametrize("channels_edge, thread_edge", list(product(EDGES, repeat=2))) def test_every_edge_combination_stays_inside_native_viewport(channels_edge, thread_edge): layout = SidebarLayout() - layout.move("channels-sidebar", channels_edge) - layout.move("thread-sidebar", thread_edge) + layout.move(ChannelsSidebarPlacement, channels_edge) + layout.move(ThreadSidebarPlacement, thread_edge) viewport = Size(52, 70) resolved = layout.resolve(viewport, {key: False for key in layout.placements}) bars = tuple(resolved.bars.values()) diff --git a/tests/textual/test_sidebar_pointer_focus.py b/tests/textual/test_sidebar_pointer_focus.py index b8977e661..b56b2657f 100644 --- a/tests/textual/test_sidebar_pointer_focus.py +++ b/tests/textual/test_sidebar_pointer_focus.py @@ -1,9 +1,8 @@ -"""Textual-facing: pointer clicks on a sidebar toggle keep focus in the editor (no handle focus, no footer rebuild); keyboard toggles still enter the pane.""" - -from unittest.mock import PropertyMock, patch +"""Textual-facing: pointer toggles preserve editor/footer resources; keyboard toggles enter the pane.""" from textual.widgets import Footer -from toad.widgets.side_bar import SideBar, SideBarToggle +from toad.sidebar_layout import SidebarPlacement +from toad.widgets.side_bar import SideBarToggle async def test_pointer_toggles_keep_editing_focus_and_keyboard_toggle_enters_the_pane(workspace): @@ -11,22 +10,23 @@ async def test_pointer_toggles_keep_editing_focus_and_keyboard_toggle_enters_the app, pilot = ui.textual prompt = ui.conversation.prompt.prompt_text_area await ui.write_draft("retained draft") - for identity in ("channels-sidebar", "thread-sidebar"): - bar = app.selected_session.query_one(f"#{identity}", SideBar) if identity == "thread-sidebar" \ - else app.screen.query_one(f"#{identity}", SideBar) - toggle = bar.query_one(SideBarToggle) + bars = app.workspace_chrome.presented_sidebars() + assert {bar.placement_identity for bar in bars} == set(SidebarPlacement.members_with(SidebarPlacement)) + for bar in bars: + toggle = bar.toggle_control prompt.focus() await ui.step() footer = app.screen.query_one(Footer) - with patch.object(footer, "recompose", wraps=footer.recompose) as recompose: - for _ in range(4): - before = bar.collapsed - assert await pilot.click(toggle) - await ui.step() - assert bar.collapsed != before - assert app.focused is prompt, (identity, type(app.focused).__name__) - assert ui.draft() == "retained draft" - assert recompose.call_count == 0 + footer_children = tuple(footer.walk_children()) + for _ in range(4): + before = bar.collapsed + assert await pilot.click(toggle) + await ui.step() + assert bar.collapsed != before + assert app.focused is prompt, (bar.placement_identity, type(app.focused).__name__) + assert ui.draft() == "retained draft" + assert app.screen.query_one(Footer) is footer + assert tuple(footer.walk_children()) == footer_children # Focus remains available for users reaching the handle by Tab. if not bar.collapsed: bar.toggle() @@ -37,9 +37,9 @@ async def test_pointer_toggles_keep_editing_focus_and_keyboard_toggle_enters_the assert not bar.collapsed title = bar.query_one("SideBarCollapsible CollapsibleTitle") assert app.focused is title - # Closing while focus is inside the pane returns it to input. - with patch.object(type(app.screen), "focus_chain", new_callable=PropertyMock, - side_effect=AssertionError("Hidden pane rebuilt the full focus chain")): - assert await pilot.click(toggle) - await ui.step() + # Exercise native focus selection, not an overridden property that + # deliberately sends Textual down its foreign-chain path. + assert await pilot.click(toggle) + await ui.step() assert bar.collapsed and app.focused is prompt + assert ui.draft() == "retained draft" From 63666a50b0e13b93c9d81ac014a100c7598108a0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 17:26:54 -0500 Subject: [PATCH 20/23] Use native layout invalidation and await keyboard focus --- src/toad/screens/session_view.py | 4 ++-- src/toad/session_scope.py | 4 ++-- src/toad/workspace_chrome.py | 8 ++++---- src/toad/workspace_sessions.py | 7 ++++--- tests/textual/test_sidebar_pointer_focus.py | 3 ++- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index 792500f60..03a7a0ed9 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -13,7 +13,7 @@ from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope from toad.navigation_target import NavigationTarget, NoTarget from toad.session_tracker import SidebarState -from toad.widgets.side_bar import ThreadSidebar +from toad.widgets.side_bar import SideBar, ThreadSidebar from toad.widget_actions import DeclaredWidgetActions from toad.widgets.message_notifications import ( NothingToRead, @@ -93,7 +93,7 @@ async def open_direct_messages(self) -> None: "Direct messages are unavailable from this view", title="Direct messages" ) - def sidebars(self): + def sidebars(self) -> tuple[SideBar, ...]: """Supply this view's native sidebar resources through its declaration.""" return () diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 22e5d3731..02a40a697 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -38,7 +38,7 @@ from agent_comms.messages import Message from agent_comms.thread_presentation import ThreadPresentation from textual.command import Provider - from textual.dom import DOMNode + from toad.widgets.side_bar import SideBar from toad.agent_schema import AgentDefinition from toad.comms_root import CoordinationAccess @@ -362,7 +362,7 @@ def thread_identity(self) -> SessionIdentity: ... def belongs_to_wire(self, root: str | Path) -> bool: ... @abstractmethod - def sidebars(self) -> tuple[DOMNode, ...]: ... + def sidebars(self) -> tuple[SideBar, ...]: ... @abstractmethod def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: ... diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 452e23dc8..619b7480b 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -71,8 +71,8 @@ def refresh_layout(self) -> None: bars = self.presented_sidebars() for bar in bars: bar.present_controls() - for parent in {bar.parent for bar in bars}: - parent.refresh(repaint=False, layout=True) + # Native invalidation retires this resource's parent arrangement. + bar.refresh(repaint=False, layout=True) def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True @@ -83,8 +83,8 @@ def arrange( resolved = layout.resolve(size, {bar.placement_identity: bar.collapsed for bar in bars}) # The outer host stays full width. Its session's own native layout # clears both gutters, including the shared channel surface. - nested = parent is self.channels.parent and any( - bar.parent is not parent and bar.parent.layout is self for bar in bars + nested = self.channels in parent.children and any( + bar not in children for bar in bars ) gutter = Spacing() if nested else resolved.gutter placements = [ diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 8c86117f7..57fec3c6d 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from toad.app import ToadApp from toad.widget_actions import DeclaredWidgetActions + from toad.widgets.side_bar import SideBar def welcome_view() -> "SessionScope": @@ -63,7 +64,7 @@ def focus_sidebar_return(self) -> None: ... def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: ... @abstractmethod - def sidebars(self): + def sidebars(self) -> tuple[SideBar, ...]: """Native sidebar membership belongs to this logical source.""" ... @@ -93,7 +94,7 @@ def focus_sidebar_return(self) -> None: def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: return NoTarget() - def sidebars(self): + def sidebars(self) -> tuple[SideBar, ...]: return () @@ -137,7 +138,7 @@ def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> Navi else NoTarget() ) - def sidebars(self): + def sidebars(self) -> tuple[SideBar, ...]: return self.view.sidebars() def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: diff --git a/tests/textual/test_sidebar_pointer_focus.py b/tests/textual/test_sidebar_pointer_focus.py index b56b2657f..3c0db1418 100644 --- a/tests/textual/test_sidebar_pointer_focus.py +++ b/tests/textual/test_sidebar_pointer_focus.py @@ -32,10 +32,11 @@ async def test_pointer_toggles_keep_editing_focus_and_keyboard_toggle_enters_the bar.toggle() await ui.step() toggle.focus(scroll_visible=False) - await ui.step() + await ui.until(lambda: app.focused is toggle, why="native focus on the keyboard handle") await ui.press("enter") assert not bar.collapsed title = bar.query_one("SideBarCollapsible CollapsibleTitle") + await ui.until(lambda: app.focused is title, why="the queued native focus enters the title") assert app.focused is title # Exercise native focus selection, not an overridden property that # deliberately sends Textual down its foreign-chain path. From a0b9bfdf9b22314e1c00e9522e9646efb3a51ac5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 17:40:26 -0500 Subject: [PATCH 21/23] Resolve sidebar provider types in their runtime namespace --- src/toad/workspace_sessions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 57fec3c6d..4ad4ac977 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -16,11 +16,11 @@ SessionOrigin, SessionScope, ) +from toad.widget_actions import DeclaredWidgetActions +from toad.widgets.side_bar import SideBar if TYPE_CHECKING: from toad.app import ToadApp - from toad.widget_actions import DeclaredWidgetActions - from toad.widgets.side_bar import SideBar def welcome_view() -> "SessionScope": From de0a65aad951d247be5572ba34b4d5d99fe4e70e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 20:00:35 -0400 Subject: [PATCH 22/23] Keep editor submit intent with its queued callback --- src/toad/prompt_actions.py | 20 ++++-- src/toad/widgets/prompt.py | 71 ++++++++++++++++------ tests/textual/test_prompt_enter_insert.py | 74 ++++++++++++++++++++++- 3 files changed, 139 insertions(+), 26 deletions(-) diff --git a/src/toad/prompt_actions.py b/src/toad/prompt_actions.py index d12a7ede9..da1cb7886 100644 --- a/src/toad/prompt_actions.py +++ b/src/toad/prompt_actions.py @@ -3,6 +3,7 @@ from textual.document._editing import VimMode from textual.widgets.text_area import EditingMode from toad.widget_actions import Action +from toad.core.input_events import UserInputSubmitted from typing import TYPE_CHECKING @@ -47,6 +48,15 @@ class SubmitInputAction(PromptAction, declared_name=None): def available(self, editor): return editor.submission_allowed + async def apply(self, editor: PromptTextArea) -> None: + editor.schedule_submission(self) + + def coalesce(self, following: SubmitInputAction) -> SubmitInputAction: + return following + + def submission_event(self, text: str, shell: bool) -> UserInputSubmitted: + return UserInputSubmitted(text, shell) + class SubmitAction(SubmitInputAction): """Send the prompt to the agent.""" @@ -54,9 +64,6 @@ class SubmitAction(SubmitInputAction): title = "Send" key_display = "⏎" - async def apply(self, editor: PromptTextArea) -> None: - editor.schedule_submission() - class SubmitNowAction(SubmitInputAction): """Send the prompt now, ahead of the queue.""" @@ -68,8 +75,11 @@ def available(self, editor): return (super().available(editor) and editor.prompt.agent_ready and editor.agent_busy and editor.prompt.agent_binding.presentation.queue_supported) - async def apply(self, editor: PromptTextArea) -> None: - editor.schedule_submission(immediate=True) + def coalesce(self, following: SubmitInputAction) -> SubmitInputAction: + return self + + def submission_event(self, text: str, shell: bool) -> UserInputSubmitted: + return UserInputSubmitted(text, shell, immediate=True) class CopyOrClearInputAction(PromptAction): diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 81d059387..f7b32471c 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -2,7 +2,8 @@ import asyncio import shlex -from dataclasses import replace +from abc import abstractmethod +from dataclasses import dataclass, replace from pathlib import Path from types import FunctionType from agent_comms.repo import Checkout @@ -36,13 +37,12 @@ from toad.agent_binding import NoAgentConnection from toad.conversation_turn import ConversationTurn from toad.core import input_events -from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventReceiver from toad.input_decision import InputAccepted, InputDecision from toad.key_modes import Standard from toad.tone_paint import TonePaint from toad.path_complete import PathComplete -from toad.prompt_actions import PromptAction, SubmitNowAction +from toad.prompt_actions import PromptAction, SubmitInputAction, SubmitNowAction from toad.prompt_cursor import ( CommandText, HistoryCursor, @@ -184,23 +184,57 @@ def on_mouse_down(self, event: events.MouseUp) -> None: self.query_ancestor(Prompt).focus() -class PromptSubmission: +class PromptSubmissionState: + """A coalesced editor request owns its intent until its callback takes it.""" + + def request(self, editor: PromptSubmission, action: SubmitInputAction) -> PromptSubmissionState: + # Queue behind the widget's earlier input events, never a timer. + if editor.call_later(editor.submit_pending): + return PendingPromptSubmission(action, editor.editing_mode) + return self + + async def submit(self, editor: PromptSubmission) -> None: + pass + + +class NoPromptSubmission(PromptSubmissionState): + pass + + +@dataclass(frozen=True) +class PendingPromptSubmission(PromptSubmissionState): + action: SubmitInputAction + mode: EditingMode + + def request(self, editor: PromptSubmission, action: SubmitInputAction) -> PromptSubmissionState: + if self.mode is editor.editing_mode: + action = self.action.coalesce(action) + return PendingPromptSubmission(action, editor.editing_mode) + + async def submit(self, editor: PromptSubmission) -> None: + await editor._submit(self.action, self.mode) + + +class PromptSubmission(HighlightedTextArea): """Admit model and local command submission through the current prompt.""" - def schedule_submission(self, *, immediate: bool = False) -> None: - self._submit_immediate |= immediate - # The callback is queued behind this widget's input events. Unlike a - # timer, it preserves paste/typing order without adding a fixed latency. - if not self._submit_pending: - self._submit_pending = True - self.call_later(self._submit) + def __init__(self) -> None: + self._submission: PromptSubmissionState = NoPromptSubmission() + super().__init__() + def schedule_submission(self, action: SubmitInputAction) -> None: + self._submission = self._submission.request(self, action) + async def submit_pending(self) -> None: + request, self._submission = self._submission, NoPromptSubmission() + await request.submit(self) + @abstractmethod + async def _submit(self, action: SubmitInputAction, mode: EditingMode) -> None: ... class PromptTextArea( - CoreEventReceiver, LocalMethodKeys, PromptSubmission, HighlightedTextArea, + CoreEventReceiver, LocalMethodKeys, PromptSubmission, actions=(PromptAction,), ): HELP = """\ @@ -236,8 +270,6 @@ def __init__(self, *, prompt: Prompt) -> None: self.prompt = prompt self.set_reactive(TextArea.editing_mode, prompt.initial_editing_mode) self.set_reactive(PromptTextArea.project_path, prompt.project_path) - self._submit_pending = False - self._submit_immediate = False @property def agent_busy(self) -> bool: @@ -396,10 +428,10 @@ async def action_tab_complete(self) -> None: priority=True), ) - async def _submit(self) -> None: - """Silently cancel a submission superseded by focus, mode or popup changes.""" - self._submit_pending = False - mode = self.editing_mode + async def _submit(self, action: SubmitInputAction, mode: EditingMode) -> None: + """A refused or superseded transfer consumes no text and retains no intent.""" + if mode is not self.editing_mode: + return try: if not await mode.flush(self): return @@ -407,7 +439,6 @@ async def _submit(self) -> None: return # The engine already reported its failure; never submit stale text. if mode is not self.editing_mode: return - immediate, self._submit_immediate = self._submit_immediate, False if not self.has_focus or not self.submission_allowed: return input_text = CommandText.decode_input(self.text, self.slash_commands) @@ -432,7 +463,7 @@ async def _submit(self) -> None: shell = self.shell_mode text = self.text self.clear() - self.publish_core(UserInputSubmitted(text, shell, immediate=immediate)) + self.publish_core(action.submission_event(text, shell)) if shell: self.prompt.shell_mode = False diff --git a/tests/textual/test_prompt_enter_insert.py b/tests/textual/test_prompt_enter_insert.py index d7c81476a..3631646b7 100644 --- a/tests/textual/test_prompt_enter_insert.py +++ b/tests/textual/test_prompt_enter_insert.py @@ -1,7 +1,11 @@ """Textual-facing: vim `i` asks the prompt for insert mode; the prompt owns whether its editor exists.""" +import pytest + from agent_comms.repo import Checkout -from support.agents import peer +from support.agents import RecordedEvents, peer +from toad.core.input_events import UserInputSubmitted +from toad.prompt_actions import SubmitAction, SubmitInputAction, SubmitNowAction from toad.key_modes import Standard, VimInsert, VimNormal from toad.preferences import SETTINGS from toad.screens.settings import SettingsScreen @@ -83,6 +87,74 @@ async def test_real_neovim_submission_shrinks_document_and_cursor_together(works why="the local clear command, not the yielding app quit hint") +@pytest.mark.parametrize("rejected_action", (SubmitAction, SubmitNowAction)) +async def test_refused_native_transfer_cannot_change_the_next_ordinary_submission( + workspace, rejected_action: type[SubmitInputAction], +): + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: + await ui.until_agent_ready() + area = ui.conversation.prompt.prompt_text_area + ui.app.saved_settings.set(SETTINGS.ui.key_mode, VimNormal) + await ui.until(lambda: area.key_mode is VimNormal and area.editing_mode.completed, + why="the real Neovim child in Normal") + area.focus() + await ui.press("i") + await ui.until(lambda: area.editing_mode.accepts_text, why="real Insert") + await ui.type("kept draft") + await ui.press("escape") + await ui.until(lambda: area.text == "kept draft" and area.editing_mode.allows_application_commands, + why="the completed draft in real Normal") + submitted = RecordedEvents() + subscription = area.core_publications.subscribe(UserInputSubmitted, submitted.receive) + try: + await ui.press("d") + await ui.until(lambda: not area.editing_mode.completed + and not area.editing_mode.snapshot.state.transferable, + why="the actual pending Neovim operator refuses transfer") + # Exercise the queued request's refusal, including a request whose + # editing state became pending after its key was admitted. + area.schedule_submission(rejected_action()) + await ui.step() + assert area.text == "kept draft" + assert submitted == [] and workspace.prompts_received() == [] + await ui.press("escape") + await ui.until(lambda: area.editing_mode.allows_application_commands, + why="the real operator cancelled without replay") + await ui.press("i") + await ui.until(lambda: area.editing_mode.accepts_text, why="real Insert resumed") + await ui.press("enter") + await ui.until(lambda: workspace.prompts_received() == ["kept draft"], + why="one fresh ordinary submission reaches the real private peer") + assert submitted == [UserInputSubmitted("kept draft")] + finally: + subscription.close() + + +@pytest.mark.parametrize("actions,immediate", ( + ((SubmitAction, SubmitAction), False), + ((SubmitAction, SubmitNowAction, SubmitAction), True), + ((SubmitNowAction, SubmitAction), True), +)) +async def test_coalesced_prompt_actions_publish_one_owned_intent(workspace, actions, immediate): + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: + await ui.until_agent_ready() + area = ui.conversation.prompt.prompt_text_area + ui.app.saved_settings.set(SETTINGS.ui.key_mode, Standard) + await ui.until(lambda: area.key_mode is Standard, why="the native Standard editor") + area.focus() + await ui.type("one request") + submitted = RecordedEvents() + subscription = area.core_publications.subscribe(UserInputSubmitted, submitted.receive) + try: + for action in actions: + area.schedule_submission(action()) + await ui.until(lambda: workspace.prompts_received() == ["one request"], + why="the coalesced request reaches the real private peer once") + assert submitted == [UserInputSubmitted("one request", immediate=immediate)] + finally: + subscription.close() + + async def test_standard_chooser_retires_neovim_and_saved_selection_reopens(workspace): async with workspace.app() as ui: app = ui.app From 0b451c8e2b7745c128a7ffc92ad0a009aad78a12 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 21:09:22 -0500 Subject: [PATCH 23/23] Read the mode chooser through SettingsScreen values --- tests/textual/test_prompt_enter_insert.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/textual/test_prompt_enter_insert.py b/tests/textual/test_prompt_enter_insert.py index 3631646b7..7f87acd0e 100644 --- a/tests/textual/test_prompt_enter_insert.py +++ b/tests/textual/test_prompt_enter_insert.py @@ -173,9 +173,9 @@ async def test_standard_chooser_retires_neovim_and_saved_selection_reopens(works await ui.press("ctrl+comma") await ui.until(lambda: isinstance(app.screen, SettingsScreen), why="the application settings binding before Neovim") - await ui.until(lambda: str(SETTINGS.ui.key_mode) in app.screen.form.values, + await ui.until(lambda: str(SETTINGS.ui.key_mode) in app.screen.values, why="the existing declared mode chooser") - chooser = app.screen.form.values[str(SETTINGS.ui.key_mode)].editor + chooser = app.screen.values[str(SETTINGS.ui.key_mode)].editor assert chooser.lists(Standard) chooser.value = Standard await ui.until(lambda: app.settings.ui.key_mode is Standard and area.key_mode is Standard,