From 1aa5982cad3aef7418121def3a7f35a667f2f1f9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:12:43 -0400 Subject: [PATCH 01/14] Give channel rows their own alternating hue tones in the sidebar Thread rows already alternate between the sidebar tone and one step off it. Channel headers now alternate on their own pair, stepped from the sidebar tone toward the theme's primary hue, so the channel list and its threads read as two families without separator rows. One mechanism owns alternation: AlternatingTone (tone_paint.py) holds the row's parity and its declared tone pair. Thread rows keep the neutral pair; ChannelHeader declares the hue pair. The reactive flag and the tone branch on CommsRow are deleted. The hue tones are derived colours declared beside the other Textual-backend colours; native ANSI palettes resolve them to the background like every derived tone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/textual_colours.py | 32 +++++++++++++++++++++++++++++++ src/toad/tone_paint.py | 24 ++++++++++++++++++++++- src/toad/widgets/comms_sidebar.py | 30 ++++++++++++++++++----------- src/toad/widgets/sidebar_tree.py | 8 ++++---- 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/toad/textual_colours.py b/src/toad/textual_colours.py index 76e601bf4..be9083e62 100644 --- a/src/toad/textual_colours.py +++ b/src/toad/textual_colours.py @@ -55,8 +55,11 @@ ColourScheme, ForegroundColour, LayerColour, + PrimaryColour, + SidebarToneColour, TextStyle, ThemeColour, + ToneColour, ) from ui_theme.splash import TextEffect from ui_theme.wal import WalFile @@ -84,6 +87,35 @@ def on_any_event(self, event: FileSystemEvent) -> None: self.colours.app.call_from_thread(self.colours.read_wal) +class HueToneColour(ToneColour): + """A sidebar surface stepped toward a theme hue instead of toward the foreground. + + Rows on it read as a different family from rows on the neutral sidebar + tones. Without RGB (a native ANSI palette) it is the background, like + every derived tone. + """ + + hue: ClassVar[type[ThemeColour]] = PrimaryColour + + @classmethod + def resolve_rgb(cls, read: Callable[[type[ThemeColour]], Rgb]) -> Rgb: + return SidebarToneColour.resolve_rgb(read).blend(read(cls.hue), cls.amount) + + +class ChannelRowToneColour(HueToneColour): + """A channel row of the channel list.""" + + name = "tone-channel-row" + amount = 0.13 + + +class ChannelAlternateRowToneColour(HueToneColour): + """Every other channel row, one more step toward the hue.""" + + name = "tone-channel-row-alternate" + amount = 0.2 + + class ClockCadence: """The animation clock's cadence; this base is the stopped one, with no tick due.""" diff --git a/src/toad/tone_paint.py b/src/toad/tone_paint.py index 884e87154..8dfaca056 100644 --- a/src/toad/tone_paint.py +++ b/src/toad/tone_paint.py @@ -6,7 +6,7 @@ from textual._paint_state import PaintLayer from textual.color import TRANSPARENT, Color -from ui_theme.scheme import BackgroundColour, ThemeColour +from ui_theme.scheme import AlternateRowToneColour, BackgroundColour, ThemeColour if TYPE_CHECKING: from textual.css.styles import StylesBase @@ -68,3 +68,25 @@ class UntintedTonePaint(TonePaint): """Keep the parent's area tone without an additional focus tint.""" background_tint = TRANSPARENT + + +class AlternatingTone(TonePaint): + """A list row on one of two tones by its position: rows separate without separator lines. + + A row family declares its own pair; the list that orders rows places them. + """ + + row_tones: ClassVar[tuple[type[ThemeColour] | None, type[ThemeColour] | None]] = ( + None, AlternateRowToneColour) + _alternate = False + + @property + def tone(self) -> type[ThemeColour] | None: + return self.row_tones[self._alternate] + + def place(self, index: int) -> None: + """The row is now at `index` of its list.""" + alternate = bool(index % 2) + if alternate != self._alternate: + self._alternate = alternate + self.refresh_paint() diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index bbb885ca8..a1eff054b 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -28,7 +28,6 @@ from ui_theme.effects import NEW_MESSAGE from textual.widgets import Static from textual._activation import PrimaryActivation -from ui_theme.scheme import AlternateRowToneColour from metaclass_registry.mro_dispatch import handles @@ -44,6 +43,9 @@ from toad.widget_actions import CursorAction, HiddenActions, OpenSelectedAction, OpensSelected from toad.widgets.session_sort import ChannelListSort, SessionSort from toad.widgets.sidebar_tree import SidebarChange, SidebarGroup, SidebarModelView, SearchFollower, TargetTree, TargetTreeMember, UnpaintedChannel +from toad.tone_paint import AlternatingTone +from toad.textual_colours import ChannelAlternateRowToneColour, ChannelRowToneColour +from toad.widgets.sidebar_viewport import SidebarHeader from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarPanelWidget, SidebarVisibilityObserver from toad.navigation_target import NavigationTarget @@ -68,9 +70,20 @@ def primary_activation(self): return self.query_ancestor(ChannelGroup).row.open_selected +class ChannelHeader(AlternatingTone, SidebarHeader): + """A channel's header row, across its disclosure, name and controls. + + Channels alternate on their own hue-stepped pair, so the channel list and + the thread rows under it read as two families. + """ + + row_tones = (ChannelRowToneColour, ChannelAlternateRowToneColour) + + class ChannelGroup(SidebarGroup["ThreadRow"]): """A view of one channel row of the sidebar model and the thread rows it lists.""" navigation_header = True + header_type = ChannelHeader def __init__(self, row: CommsRow, *, expanded: bool): self.channel: ChannelRowModel = UnpaintedChannel(row.target_name) self.sort_control = SessionSort(channel=row.target_name) @@ -150,20 +163,12 @@ def repaint_members(self, names: frozenset[str]) -> None: class CommsRow( - TargetTreeMember, OpensSelected, PrimaryActivation, CoreEventReceiver, HoverSelection, ViewLabel, + TargetTreeMember, OpensSelected, PrimaryActivation, CoreEventReceiver, AlternatingTone, HoverSelection, ViewLabel, scopes={HiddenActions: (CursorAction, OpenSelectedAction)}, ): """One interactive row: a channel or a thread.""" text_fades = (NEW_MESSAGE,) - alternate: reactive[bool] = reactive(False, repaint=False) - - def watch_alternate(self) -> None: - self.refresh_paint() - - @property - def tone(self): - return AlternateRowToneColour if self.alternate else None @property def target_name(self) -> str: @@ -481,7 +486,10 @@ def member_keys(self): def order_groups(self) -> None: """Order the groups admitted by sync; removed channels retire before member work.""" - ordered = (self.new_session, *(self.groups[key] for key in self.model_state.shown.channels.rows if key in self.groups)) + groups = tuple(self.groups[key] for key in self.model_state.shown.channels.rows if key in self.groups) + for index, group in enumerate(groups): + group.header.place(index) + ordered = (self.new_session, *groups) live = tuple(child for child in self.children if not child.is_leaving) if live != ordered: positions = {widget: index for index, widget in enumerate(ordered)} diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 396406d22..de7b4bb64 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -224,6 +224,7 @@ class SidebarGroup[RowT: "CommsRow"](KeyedRows[str], VerticalGroup): """A common header/disclosure and optional bounded member viewport.""" navigation_header = False + header_type: type[SidebarHeader] = SidebarHeader DEFAULT_CSS = """ SidebarGroup { height: auto; } @@ -255,12 +256,13 @@ def __init__( self.row.add_class("group-title") self.disclosure = disclosure_type() self.controls = controls + self.header = self.header_type(classes="group-header") self.member_container = VerticalGroup(classes="group-members channel-members") self.member_container.styles.layout = SidebarReaderLayout() self.expanded = expanded def compose(self) -> ComposeResult: - with SidebarHeader(classes="group-header"): + with self.header: yield self.disclosure yield self.row yield from self.controls @@ -349,9 +351,7 @@ def update_row(self, key: str, row: RowT) -> None: self.show_member(key, row) def place_row(self, row: RowT, index: int) -> None: - alternate = bool(index % 2) - if row.alternate != alternate: - row.alternate = alternate + row.place(index) @property def member_keys(self): From da23da72f1376035ffc82684aec25fa029b06669 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:17:27 -0400 Subject: [PATCH 02/14] Lay the tab underline out under the tab cells and slide it between tabs Root cause of the desync: the underline was a 1fr child of the scrolling tab strip, so it was exactly one viewport wide while the cells it marks are as wide as all tabs. Once tabs overflowed, any selected tab past the first viewport width of cells had a span beyond the underline's last column and was drawn with no underline at all (probe: strip 100 wide, cells 206, selected span 154-167, underline 0-100). The cells and the underline now share one parent, TabCells, which is as wide as the cells or the strip, whichever is wider; the underline spans exactly the cells' columns by layout. The marked span is the tab's whole cell (label and close button), not its text. Motion: when the marked cells move (another tab chosen, a tab opened, closed, renamed or reordered) the underline slides from what it drew to the new cells with TAB_SLIDE on the app clock, one row repainted per frame. Tabs that open or move rise into place with the existing NEW_TEXT fade. The whole-track NEW_TEXT fade that flashed the full underline on every switch is deleted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/widgets/session_tabs.py | 86 +++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index 880b5d584..1cbbf17e3 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -9,14 +9,14 @@ from toad.widget_actions import ActivationKeys from textual.color import TRANSPARENT from textual.content import Content -from textual.css.scalar import Scalar, ScalarOffset +from textual.css.scalar import Scalar, ScalarOffset, Unit from textual.geometry import Offset, Region, Spacing from textual.layouts.vertical import VerticalLayout from textual.message import Message from textual.renderables.bar import Bar from textual.style import Style from textual.widget import Widget -from ui_theme.effects import NEW_TEXT, Mix +from ui_theme.effects import NEW_TEXT, Amount, Transition from ui_theme.scheme import ( AccentColour, ErrorColour, @@ -125,13 +125,32 @@ def primary_activation(self): def close_tab(self) -> None: self.app.session_navigation.events.publish(session_requests.SessionArchive(self.mode_name)) -class Underline(WidgetView, Animated[Mix]): - """The chosen tab's underline uses the shared content-change fade.""" +# The selected tab's underline travels from the cells it marked to the new ones. +TAB_SLIDE = Transition(Amount, 0.0, 1.0, duration=0.18, frames_per_second=60) + + +class Underline(WidgetView, Animated[Amount]): + """The track under the tab cells, marking the selected tab's cells. + + It is laid out under the cells themselves (TabCells), so it spans every + cell however far the strip scrolls. When the marked cells move (another tab + chosen, tabs reordered, closed, opened or renamed) it slides from what it + drew to them, on the app clock; it repaints one row per frame. + """ - text_fades = (NEW_TEXT,) width = FILL height = Scalar.from_number(1) - placement_offset = ScalarOffset.from_offset((0, 1)) + # The cells it drew last: where the next slide starts. + _drawn: tuple[float, float] = (0.0, 0.0) + _origin: tuple[float, float] = (0.0, 0.0) + + def follow(self) -> None: + """The selected cells may have moved; slide to them from what is drawn.""" + start, end = self._drawn + if start < end and self._drawn != self.selected_cells: + self._origin = self._drawn + self.run_effect(TAB_SLIDE) + self.refresh() class Clicked(Message): """Inform ancestors the underline was clicked.""" @@ -144,16 +163,21 @@ def __init__(self, offset: Offset) -> None: super().__init__() @property - def _highlight_range(self) -> tuple[int, int]: + def selected_cells(self) -> tuple[float, float]: """The selected tab's cells, read from its label when the bar is drawn.""" - return self.query_ancestor(SessionsTabs).selected_span + start, end = self.query_ancestor(SessionsTabs).selected_span + return float(start), float(end) def render(self) -> RenderResult: """Render the bar.""" colours = self.app.colours background = colours.colour(HeaderToneColour) + colours.colour(ForegroundColour).multiply_alpha(0.1) + amount = self.effect_frame(TAB_SLIDE).value + (from_start, from_end), (to_start, to_end) = self._origin, self.selected_cells + self._drawn = (from_start + (to_start - from_start) * amount, + from_end + (to_end - from_end) * amount) return Bar( - highlight_range=self._highlight_range, + highlight_range=self._drawn, highlight_style=RichStyle.from_color(colours.colour(AccentColour).rich_color), background_style=RichStyle.from_color(background.rich_color), ) @@ -165,8 +189,22 @@ def _on_click(self, event: events.Click): class SessionTitles(WidgetView, containers.HorizontalGroup): + """The tab cells: each tab's label and close button.""" + width = AUTO height = Scalar.from_number(1) + + +class TabCells(WidgetView, containers.VerticalGroup): + """The tab cells over their underline, as wide as the cells or the strip, whichever is wider. + + The underline's columns are the cells' columns by layout, so the selected + tab's span needs no translation and is never past the underline's end. + """ + + width = AUTO + min_width = Scalar(100, Unit.PERCENT, Unit.WIDTH) + height = Scalar.from_number(2) placement_offset = ScalarOffset.from_offset((0, 1)) @@ -227,14 +265,17 @@ async def handle_mode_change(self, event: core_events.SessionSelected) -> None: label.paint_current() self.call_later(self._sync_tabs) self.call_after_refresh(self.show_selected_tab) - self.query_one(Underline).run_effect(NEW_TEXT) @property def selected_span(self) -> tuple[int, int]: - """The cells under the selected tab's label; empty until that label is laid out.""" + """The selected tab's cells, label and close button; empty until they are laid out.""" selected = self.app.selected_mode - label = self.query_one_optional(f"#{selected}", SessionLabel) if selected else None - return (0, 0) if label is None else label.virtual_region.shrink((0, 1, 0, 1)).column_span + if not selected: + return 0, 0 + cells = [cell.virtual_region for cell in ( + self.query_one_optional(f"#{selected}", SessionLabel), + self.query_one_optional(f"#close-{selected}", SessionTabClose)) if cell is not None] + return Region.from_union(cells).column_span if cells else (0, 0) def show_selected_tab(self) -> None: """Redraw the underline and bring the selected tab into the strip. @@ -244,7 +285,7 @@ def show_selected_tab(self) -> None: """ if not self.is_mounted or not self.is_attached: return - self.query_one(Underline).refresh() + self.query_one(Underline).follow() selected = self.app.selected_mode if selected and (label := self.query_one_optional(f"#{selected}", SessionLabel)): if label.virtual_region: @@ -253,11 +294,12 @@ def show_selected_tab(self) -> None: def compose(self) -> ComposeResult: tabs = self.app.open_tabs self._last_tabs = {tab.mode_name: tab for tab in tabs} - with SessionTitles(id="title-container"): - for session in tabs: - yield SessionLabel(session, id=session.mode_name) - yield SessionTabClose(session.mode_name) - yield Underline() + with TabCells(): + with SessionTitles(id="title-container"): + for session in tabs: + yield SessionLabel(session, id=session.mode_name) + yield SessionTabClose(session.mode_name) + yield Underline() @handles(SessionChangedEvent) async def handle_session_update(self, event: SessionChangedEvent) -> None: @@ -344,6 +386,12 @@ async def _reconcile_tabs(self) -> None: if [widget.id for widget in self.title_container.children] != list(order): self.title_container.sort_children(key=lambda widget: order[widget.id]) geometry_changed = True + # A tab that opened or moved among the others rises into its new cells. + before = list(previous_tabs) + for index, tab in enumerate(tabs): + if before[index:index + 1] != [tab.mode_name] and ( + label := self.query_one_optional(f"#{tab.mode_name}", SessionLabel)): + label.run_effect(NEW_TEXT) self._last_tabs = {tab.mode_name: tab for tab in tabs} if geometry_changed: self.call_after_refresh(self.show_selected_tab) From b581e21f759a3da829f472a73f912bb471c56997 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:21:40 -0400 Subject: [PATCH 03/14] Show the chosen tab's name in a large centred toast that fades in and out Choosing a tab (or a sidebar thread or channel that opens one) puts its name in a framed toast centred over the workspace. Its frame, surface and name rise from the background, hold, and fall back on the app clock (Swell: 0.12 s rise, 0.5 s hold, 0.35 s fall, 30 fps), so the reader sees where they landed without hunting for the underline. It never takes focus or pointer input (can_focus off, hit_test_transparent), a new choice while it shows replaces the name and restarts the swell, and it leaves the layout when the swell ends. With animation off the clock reports the swell finished as soon as it starts, and the toast hides at once. EffectPhase now declares whether an effect is in motion, so a widget reads that from the phase family instead of comparing phase objects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/frame_presentation.py | 9 +++ src/toad/screens/workspace.py | 1 + src/toad/widgets/switch_toast.py | 126 +++++++++++++++++++++++++++++++ src/toad/workspace_chrome.py | 2 + 4 files changed, 138 insertions(+) create mode 100644 src/toad/widgets/switch_toast.py diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index 378286c33..a9d5b890b 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -201,6 +201,11 @@ async def wait(self) -> bool: class EffectPhase: + """A running effect; its subclasses are the effect not yet started and finished.""" + + # Whether the effect is moving now. + in_motion: ClassVar[bool] = True + def paint(self, owner: AnimationEffects[Mix], effect: Effect[Mix], strip: Strip): return owner.paint_effects(strip, owner.effect_frame(effect)) @@ -212,12 +217,16 @@ def on_new_arrival(self, owner: AnimationEffects[Mix], effect: Effect[Mix], page class EffectUnstarted(EffectPhase): + in_motion = False + def paint(self, owner: AnimationEffects[Mix], effect: Effect[Mix], strip: Strip): owner.run_effect(effect) return super().paint(owner, effect, strip) class EffectFinished(EffectPhase): + in_motion = False + def keeps_arrivals[Item](self, arrivals: Iterable[Item]) -> Iterable[Item]: return () diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index 6ebd51269..7e9eec6e1 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -124,6 +124,7 @@ def compose(self): yield chrome.channels yield Container(id="workspace-session-host") yield chrome.footer + yield chrome.switch_toast def binding_scopes(self): return tuple( diff --git a/src/toad/widgets/switch_toast.py b/src/toad/widgets/switch_toast.py new file mode 100644 index 000000000..c765dc354 --- /dev/null +++ b/src/toad/widgets/switch_toast.py @@ -0,0 +1,126 @@ +"""The name of the tab just chosen, large and centred over the workspace, fading in and out.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from metaclass_registry.mro_dispatch import handles +from rich.cells import cell_len, set_cell_size +from rich.segment import Segment +from rich.style import Style +from textual.geometry import Offset +from textual.strip import Strip +from textual.css.scalar import Scalar +from ui_theme.effects import Amount, Effect +from ui_theme.scheme import AccentColour, BackgroundColour, ForegroundColour, HeaderToneColour + +from toad.core import events as core_events +from toad.core_event_carrier import CoreEventReceiver +from toad.frame_presentation import Animated +from toad.widget_view import WidgetView + +if TYPE_CHECKING: + from textual._paint_state import PaintState + + +@dataclass(frozen=True) +class Swell(Effect[Amount]): + """Rises to full, holds, then falls back to nothing; it ends at nothing.""" + + rise: float + hold: float + fall: float + frames_per_second: float + + @property + def duration(self) -> float: # type: ignore[override] + return self.rise + self.hold + self.fall + + def frame(self, elapsed: float) -> Amount: + rising = self.ease_out(self.progress(elapsed, self.rise)) + falling = self.ease_out(self.progress(self.duration - elapsed, self.fall)) + return Amount(min(rising, falling)) + + @property + def final(self) -> Amount: + return Amount(0.0) + + +TAB_TOAST = Swell(rise=0.12, hold=0.5, fall=0.35, frames_per_second=30) + + +class SwitchToast(CoreEventReceiver, WidgetView, Animated[Amount]): + """The chosen tab's name, so the reader sees where they landed without finding the tab. + + It never takes focus or pointer input: clicks pass to what is under it. + A new choice while it shows replaces its name and restarts its swell. + It is laid out only when it appears and when it ends; every frame between + repaints its own few rows. + """ + + can_focus = False + hit_test_transparent = True + position = "absolute" + overlay = "screen" + # Cells around the name, inside the frame. + MARGIN = (1, 6) + + def __init__(self) -> None: + super().__init__() + self.title = "" + self.display = False + + def on_mount(self) -> None: + self.observe_core(self.app.events) + + @handles(core_events.SessionSelected) + def session_selected(self, event: core_events.SessionSelected) -> None: + tabs = {tab.mode_name: tab for tab in self.app.open_tabs} + if (tab := tabs.get(event.mode_name)) is not None: + self.show(tab.title) + + @property + def box(self) -> tuple[int, int]: + rows, columns = self.MARGIN + width = min(cell_len(self.title) + 2 * columns + 2, max(0, self.screen.size.width - 4)) + return width, 2 * rows + 3 + + def show(self, title: str) -> None: + self.title = title + width, height = self.box + self.width, self.height = Scalar.from_number(width), Scalar.from_number(height) + screen = self.screen.size + self.absolute_offset = Offset((screen.width - width) // 2, (screen.height - height) // 2) + self.display = True + self.run_effect(TAB_TOAST) + self.refresh(layout=True) + + def on_show(self) -> None: + super().on_show() + self.repaint() + + def repaint(self) -> None: + """The clock's paint request: the toast leaves the layout once its swell ends.""" + self.display = self.effect_phase(TAB_TOAST).in_motion + self.refresh() + + def render_line(self, y: int, paint: PaintState) -> Strip: + colours = self.app.colours + amount = self.effect_frame(TAB_TOAST).value + background = colours.mixed(BackgroundColour, HeaderToneColour, amount).rich_color + frame = Style(color=colours.mixed(BackgroundColour, AccentColour, amount).rich_color, bgcolor=background) + name = Style(color=colours.mixed(BackgroundColour, ForegroundColour, amount).rich_color, + bgcolor=background, bold=True) + width, height = self.box + inner = max(0, width - 2) + if y == 0 or y == height - 1: + left, right = ("╭", "╮") if y == 0 else ("╰", "╯") + return Strip([Segment(left + "─" * inner + right, frame)], width) + text = self.title if y == height // 2 else "" + if cell_len(text) > inner: + text = set_cell_size(text, max(0, inner - 1)) + "…" + padding = inner - cell_len(text) + return Strip([Segment("│", frame), + Segment(" " * (padding // 2) + text + " " * (padding - padding // 2), name), + Segment("│", frame)], width) diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 4b2ba0dd2..87991f935 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -9,6 +9,7 @@ from textual.widget import Widget from toad.widgets.channels_sidebar import ChannelsSidebar from toad.widgets.footer import ToadFooter +from toad.widgets.switch_toast import SwitchToast from ui_theme.scheme import HeaderToneColour from toad.tone_paint import TonePaint from toad.widget_view import AUTO, FILL, WidgetView @@ -39,6 +40,7 @@ def __init__(self, app: "ToadApp") -> None: self.navigation = WorkspaceHeader(id="tab-navigation-header") self.channels = ChannelsSidebar() self.footer = ToadFooter(id="workspace-footer") + self.switch_toast = SwitchToast() self.native = NativeSessionSurface(app) def presented_sidebars(self): From 4c46986f39cf19e3754d433ba6391fc7d9e5b015 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:26:21 -0400 Subject: [PATCH 04/14] Uncover arriving messages from their head and keep a tall one's head in view Before: an append at the followed end kept the window on the tail, so a message taller than the window appeared as one block already scrolled to its last row, and the reader scrolled up to find who sent it. Now the first message of an arriving burst is the reader's position for the layout that adds it (ArrivalPosition, a member of the ReaderPosition family beside FragmentPosition and BlockPosition). Its page is a geometry target, so that same layout places it: a message that fits follows the tail exactly as before; one taller than the window lands with its first row at the window's top and following stops there, the same rule message_head_hidden already applies to a clicked tall message. Reader movement during the change wins. Each burst also uncovers its rows from each message's first row down, one window height per sweep (ARRIVAL_REVEAL, NEW_TEXT's duration, 60 fps, on the app clock), alongside the existing rise-from-background fade. Arrivals inside a running burst join it rather than restarting it; once the sweep ends every row is uncovered, and with animation off nothing is ever covered. The effect phase family now starts the reveal where it already decided whether arrivals begin a new burst. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/frame_presentation.py | 12 +++++--- src/toad/widgets/history_anchor.py | 41 ++++++++++++++++++++++++++ src/toad/widgets/transcript_history.py | 36 +++++++++++++++++++++- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index a9d5b890b..9f630d986 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from toad.screens.workspace import WorkspaceFrame - from toad.widgets.transcript_history import TranscriptPageView + from toad.widgets.transcript_history import LineHistory, TranscriptPageView class FrameState(DeclaredFamily, affix="Frame"): @@ -212,8 +212,8 @@ def paint(self, owner: AnimationEffects[Mix], effect: Effect[Mix], strip: Strip) def keeps_arrivals[Item](self, arrivals: Iterable[Item]) -> Iterable[Item]: return arrivals - def on_new_arrival(self, owner: AnimationEffects[Mix], effect: Effect[Mix], pages: deque[TranscriptPageView]): - pass + def on_new_arrival(self, owner: LineHistory, effect: Effect[Mix], pages: deque[TranscriptPageView]): + """Arrivals during a running burst join its fade and reveal.""" class EffectUnstarted(EffectPhase): @@ -223,6 +223,9 @@ def paint(self, owner: AnimationEffects[Mix], effect: Effect[Mix], strip: Strip) owner.run_effect(effect) return super().paint(owner, effect, strip) + def on_new_arrival(self, owner: LineHistory, effect: Effect[Mix], pages: deque[TranscriptPageView]): + owner.arrivals_began() + class EffectFinished(EffectPhase): in_motion = False @@ -230,10 +233,11 @@ class EffectFinished(EffectPhase): def keeps_arrivals[Item](self, arrivals: Iterable[Item]) -> Iterable[Item]: return () - def on_new_arrival(self, owner: AnimationEffects[Mix], effect: Effect[Mix], pages: deque[TranscriptPageView]): + def on_new_arrival(self, owner: LineHistory, effect: Effect[Mix], pages: deque[TranscriptPageView]): for page in pages: page.arrivals.clear() owner.stop_effect(effect) + owner.arrivals_began() EFFECT_UNSTARTED, EFFECT_RUNNING, EFFECT_FINISHED = EffectUnstarted(), EffectPhase(), EffectFinished() diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index e754707d1..69f46c315 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -325,6 +325,47 @@ def destination(self, window: "HistoryWindow") -> float: return window.row_of(self.block) + self.y - self.row +@dataclass(frozen=True) +class ArrivalPosition(OffsetPosition): + """A message that just arrived at the followed end, on the page that draws it. + + One that fits in the window follows the tail as before. One taller than + the window is placed with its first row at the window's top instead of + its last, so the reader sees it arrive from its head; following stops + there, as when the reader scrolls up to read. The page is a geometry + target, so the layout that adds the message also places it. + """ + + page: TranscriptPageView + key: FragmentKey + + def location(self) -> FragmentLocation: + return self.page.fragment_row(self.key, 0) + + def bodies(self, window: "HistoryWindow") -> tuple[Widget, ...]: + return (self.page,) + + def for_layout(self, window: HistoryWindow, position: ReaderPosition) -> ReaderPosition: + if self.page.is_attached and window.displays(self.page): + return position + return position.without_holder(window) + + def laid_out(self, window: HistoryWindow) -> bool: + return self.location().laid_out(window) + + def destination(self, window: "HistoryWindow") -> float: + return self.location().destination(window, 0.0) + + def place(self, window: "HistoryWindow") -> bool: + if window.message_head_hidden(self.page.drawn_height(self.location().location().fragment), None): + return super().place(window) + return TailPosition().place(window) + + def shifted(self, rows: float) -> ReaderPosition: + # The reader moved while it arrived: their movement decides, not the arrival. + return TailPosition() + + @dataclass(frozen=True) class UnreachedPosition(ReaderPosition): """A position no layout has reached yet: the history is still too short, or is not drawn. diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 2a0da24db..a62ea6276 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -9,6 +9,7 @@ from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass, field, replace from functools import cached_property, partial +from math import inf from itertools import accumulate, chain, islice from typing import TYPE_CHECKING, Final from weakref import ref @@ -34,7 +35,7 @@ from textual.strip import Strip from textual.widget import Widget from textual.widgets import Static -from ui_theme.effects import NEW_TEXT +from ui_theme.effects import NEW_TEXT, Amount, Transition from toad.block_navigation import ConversationEntry, FragmentBlockCursor from toad.core.source_events import TranscriptCoverage @@ -69,6 +70,7 @@ SnapshotPresentation, ) from toad.widgets.history_anchor import ( + ArrivalPosition, FragmentLocation, FragmentNotLoaded, FragmentPosition, @@ -113,6 +115,10 @@ from toad.widgets.conversation import Contents, Conversation +# An arriving message is uncovered from its first row down, alongside NEW_TEXT's fade. +ARRIVAL_REVEAL = Transition(Amount, 0.0, 1.0, duration=NEW_TEXT.duration, frames_per_second=60) + + class HistoryEdge(Activates, Static, can_focus=True): DEFAULT_CSS = "HistoryEdge { height: 1; color: $text-muted; pointer: pointer; }" @@ -321,6 +327,8 @@ def render_line(self, y: int, paint: PaintState) -> Strip: strip = lines.strips_at(fragment.link_place)[row] if id(fragment) in self.arrivals: history = self.history + if row >= history.revealed_rows: + return selection_painted(self, self.blank_row(width, paint), y) strip = history.effect_phase(NEW_TEXT).paint(history, NEW_TEXT, strip) return selection_painted(self, self.on_background(strip, paint, y), y) @@ -357,6 +365,11 @@ def drawn_fragments(self) -> tuple[TranscriptFragment, ...]: """The fragments this page draws rows for, oldest first.""" return tuple(fragment for fragment, _first, _lines in self._layout) + def drawn_height(self, fragment: TranscriptFragment) -> int: + """The rows this page draws for a fragment it draws.""" + _fragment, _first, lines = self._layout[self._fragment_slots[id(fragment)]] + return lines.height + def fragment_rows(self, fragment: TranscriptFragment) -> range | None: """The rows of this page that draw a fragment.""" if (slot := self._fragment_slots.get(id(fragment))) is None: @@ -964,6 +977,26 @@ def on_earlier_history(self, event: HistoryEdge.Requested) -> None: def drawn_tail_followed(self) -> bool: return not self.drawn_end_more and self.follows_source_tail + # The window's height when the current burst of arrivals began. + reveal_rows = 0 + + @property + def revealed_rows(self) -> float: + """How many rows of each arriving fragment are uncovered: all of them once the sweep ends.""" + amount = self.effect_frame(ARRIVAL_REVEAL).value + return amount * self.reveal_rows if amount < 1.0 else inf + + def arrivals_began(self) -> None: + """A burst of arrivals uncovers each message from its first row down, one window height in one sweep.""" + self.reveal_rows = self.window.scrollable_content_region.height + self.run_effect(ARRIVAL_REVEAL) + + def hold_arrival(self, arrivals: tuple[TranscriptFragment, ...]) -> None: + """The window places the first arriving message: from its head when it is taller than the window.""" + if arrivals: + head = arrivals[0] + self.window.reader_position = ArrivalPosition(self.page_holding(head.key), head.key) + def repaint(self, fragments: tuple[TranscriptFragment, ...] = (), *, continuing=()) -> None: phase = self.effect_phase(NEW_TEXT) if fragments: @@ -1039,6 +1072,7 @@ def current() -> bool: arrivals = self._source_state.arriving(staged, followed_end) await self._extend_and_trim(edge, end, local, protected, staged, current=current) self._require_can_draw() + self.hold_arrival(arrivals) self.repaint(arrivals) self.rows_published() if not local: From 385eb7b8a24be8469b75fd0434bc45e632bfebf7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:30:21 -0400 Subject: [PATCH 05/14] Declare once that Toad's widgets live in a ToadApp The fact "this node's app is a ToadApp" was restated 16 times as cast("ToadApp", self.app) (12 in widgets/side_bar.py) and 13 more times as a per-class `app = getters.app(ToadApp)` descriptor, each with its own runtime import of toad.app. ToadHosted (widget_view.py, beside WorkspaceHosted) declares it once for readers and type checkers; the classes that read Toad's app inherit it and every cast and per-class descriptor is deleted, along with the imports they alone needed. Nothing changes at run time: `app` is the node's own. NRA flagged the casts as Toad's largest restated-identity finding (nra-ranked-toad-formbridge.md). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/screens/action_modal.py | 5 ++--- src/toad/screens/agent_modal.py | 4 ++-- src/toad/screens/comms.py | 7 +++---- src/toad/screens/main.py | 5 ++--- src/toad/screens/settings.py | 11 +++++----- src/toad/screens/store.py | 10 ++++----- src/toad/screens/welcome.py | 12 ++++------- src/toad/widget_view.py | 13 ++++++++++++ src/toad/widgets/comms_sidebar.py | 10 ++++----- src/toad/widgets/conversation.py | 5 ++--- src/toad/widgets/flash.py | 6 ++---- src/toad/widgets/session_tabs.py | 6 ++---- src/toad/widgets/side_bar.py | 35 ++++++++++++++++--------------- src/toad/widgets/tool_call.py | 6 ++---- 14 files changed, 66 insertions(+), 69 deletions(-) diff --git a/src/toad/screens/action_modal.py b/src/toad/screens/action_modal.py index a2e3591ee..0ab904374 100644 --- a/src/toad/screens/action_modal.py +++ b/src/toad/screens/action_modal.py @@ -10,7 +10,6 @@ from textual import widgets from textual.widget import Widget -from toad.app import ToadApp from toad.widgets.command_pane import CommandPane from toad.terminal_execution import Command, TerminalExecution from toad.agent_schema import AgentDefinition @@ -18,11 +17,12 @@ from toad.core.source_events import CommandComplete from toad.core_event_carrier import CoreEventReceiver from metaclass_registry.mro_dispatch import handles +from toad.widget_view import ToadHosted UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh" -class ActionModal(CoreEventReceiver, DismissibleScreen[int]): +class ActionModal(ToadHosted, CoreEventReceiver, DismissibleScreen[int]): """Executes an action command.""" CSS_PATH = "store.tcss" @@ -30,7 +30,6 @@ class ActionModal(CoreEventReceiver, DismissibleScreen[int]): command_pane = getters.query_one(CommandPane) ok_button = getters.query_one("#ok", widgets.Button) - app = getters.app(ToadApp) def __init__( self, diff --git a/src/toad/screens/agent_modal.py b/src/toad/screens/agent_modal.py index 643fc6ce7..3d135d6c9 100644 --- a/src/toad/screens/agent_modal.py +++ b/src/toad/screens/agent_modal.py @@ -16,9 +16,9 @@ from toad.agent_schema import AgentDefinition from toad.catalog_actions import CatalogCommandAction, LaunchAction from toad.core.session_requests import LaunchAgent -from toad.app import ToadApp from toad.preferences import SETTINGS from toad.platform import host +from toad.widget_view import ToadHosted class DescriptionContainer(containers.VerticalScroll): @@ -28,6 +28,7 @@ def allow_focus(self) -> bool: class AgentModal( + ToadHosted, PriorityActions, HiddenActions, Launches, DismissibleScreen[LaunchAgent | None], scopes={PriorityActions: (widget_actions.LaunchAction,), HiddenActions: (widget_actions.DismissAction,)}, @@ -35,7 +36,6 @@ class AgentModal( AUTO_FOCUS = "Select#action-select" - app = getters.app(ToadApp) action_select = getters.query_one("#action-select", widgets.Select) launcher_checkbox = getters.query_one("#launcher-checkbox", widgets.Checkbox) diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 28d269ebd..7846f8ac7 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -10,7 +10,7 @@ from typing import Literal from agent_comms.repo import Checkout -from textual import containers, getters, on +from textual import containers, on from textual.app import ComposeResult from textual.document._editing import EditingMode from toad.session_actions import ReturnToViewAction, CloseViewAction @@ -21,7 +21,6 @@ from toad import messages from toad.conversation_kind import ConversationKind -from toad.app import ToadApp from toad.widgets.comms_chat import CommsChatView from toad.core.input_events import SelectHistoricalIdentity from toad.core.input_events import SelectTarget @@ -32,6 +31,7 @@ from toad.widgets.session_tabs import SessionsTabs from toad.widgets.side_bar import SideBar, ThreadSidebar, TabHistoryControls from toad.widgets.thread_comms import RelationshipSort, ThreadCommsSidebar +from toad.widget_view import ToadHosted class ViewAction(WidgetAction, affix="Action"): @@ -79,7 +79,7 @@ def apply(self, context: CommsScreen) -> None: chat.prepare_prompt() -class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, can_focus=False, +class CommsScreen(ToadHosted, TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, can_focus=False, actions=(ViewAction, ReturnToViewAction, CloseViewAction)): """A channel or DM represented as a native concurrent Toad session.""" @@ -108,7 +108,6 @@ def __init__( self._content_error: BaseException | None = None self._content_loaded = False - app = getters.app(ToadApp) @property def coordination_root(self) -> str | None: diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 46cec3a20..1908bca40 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -32,7 +32,6 @@ ) from toad.acp.agent_session import SessionDisposition -from toad.app import ToadApp from toad.core import events as core_events from toad.core import input_events, session_requests from toad.core.events import SessionChangedEvent @@ -60,6 +59,7 @@ from toad.widgets.session_thread_sidebar import SessionThreadSidebar from toad.widgets.side_bar import SideBar from toad.widgets.splash_text import ThreadLoading +from toad.widget_view import ToadHosted class SessionCommands(Provider): @@ -210,7 +210,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, +class MainScreen(ToadHosted, NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, scopes={HiddenActions: (SessionNavigationAction,)}, can_focus=False): footer_compact = True @@ -234,7 +234,6 @@ def side_bar(self): project_path = var[Checkout](Required()) - app = getters.app(ToadApp) def __init__( self, diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index 9c8aa59c7..c5486af57 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -4,7 +4,7 @@ from collections.abc import Iterable -from textual import containers, events, getters +from textual import containers, events from textual.app import ComposeResult from textual.binding import ModeBinding from textual.document._editing import EditingMode @@ -13,7 +13,7 @@ from textual.geometry import Spacing from textual.color import Color -from toad.app import ScreenWidth, ToadApp +from toad.app import ScreenWidth from toad.forms.editors import SelectEditor from toad.forms.form_view import FormView from toad.forms.presentation import FormInput, FormModal @@ -21,13 +21,13 @@ from toad.widget_actions import PriorityActions from toad.widget_view import WidgetView from toad.setting_choices import SchemeName +from toad.widget_view import ToadHosted -class SchemeEditor(SelectEditor): +class SchemeEditor(ToadHosted, SelectEditor): """A colour scheme's name, picked from the schemes the running app has.""" shows = (SchemeName,) - app = getters.app(ToadApp) def options(self) -> Iterable[tuple[str, object]]: return ((name, name) for name in self.app.colours.schemes) @@ -52,10 +52,9 @@ class SettingsSearch(WidgetView, containers.VerticalGroup): padding = Spacing(1, 0, 1, 0) -class SettingsScreen(PriorityActions, FormModal[None], DismissibleScreen[None]): +class SettingsScreen(ToadHosted, PriorityActions, FormModal[None], DismissibleScreen[None]): align_horizontal = "right" align_vertical = "top" - app = getters.app(ToadApp) def __init__(self) -> None: super().__init__() diff --git a/src/toad/screens/store.py b/src/toad/screens/store.py index 24cb1a036..f1d190d0a 100644 --- a/src/toad/screens/store.py +++ b/src/toad/screens/store.py @@ -38,6 +38,7 @@ from toad.widgets.grid_select import GridSelect from toad.agent_schema import AgentDefinition from toad.agents import AgentReadError, read_agents +from toad.widget_view import ToadHosted QR = """\ @@ -145,7 +146,7 @@ def compose(self) -> ComposeResult: yield widgets.Static(agent.description, id="description") -class LauncherGridSelect(Launches, CoreEventReceiver, GridSelect): +class LauncherGridSelect(ToadHosted, Launches, CoreEventReceiver, GridSelect): HELP = """\ ## Launcher @@ -160,7 +161,6 @@ class LauncherGridSelect(Launches, CoreEventReceiver, GridSelect): """ BINDING_GROUP_TITLE = "Launcher" - app = getters.app(ToadApp) def action_details(self) -> None: if self.highlighted is None: @@ -188,8 +188,7 @@ async def launch(self) -> None: self.app.session_navigation.events.publish(session_requests.LaunchAgent(child.agent.identity)) -class Launcher(containers.VerticalGroup): - app = getters.app(ToadApp) +class Launcher(ToadHosted, containers.VerticalGroup): grid_select = getters.query_one("#launcher-grid-select", LauncherGridSelect) DIGITS = "123456789ABCDEF" @@ -332,7 +331,7 @@ async def apply(self, store: StoreScreen) -> None: await launcher.launch_highlighted() -class StoreScreen(ProfileKeys, CyclesFocus, CoreEventReceiver, Screen, actions=(LaunchSlotAction,)): +class StoreScreen(ToadHosted, ProfileKeys, CyclesFocus, CoreEventReceiver, Screen, actions=(LaunchSlotAction,)): BINDING_GROUP_TITLE = "Screen" CSS_PATH = "store.tcss" @@ -340,7 +339,6 @@ class StoreScreen(ProfileKeys, CyclesFocus, CoreEventReceiver, Screen, actions=( launcher = getters.query_one("#launcher", Launcher) container = getters.query_one("#container", Container) - app = getters.app(ToadApp) def __init__( self, name: str | None = None, id: str | None = None, classes: str | None = None diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 511fe4436..83a758702 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -7,7 +7,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast from metaclass_registry.mro_dispatch import handles from agent_comms.channel_targets import NoChannel @@ -40,10 +39,7 @@ TargetTree, ) from toad.widgets.splash_text import SplashText - -if TYPE_CHECKING: - from toad.app import ToadApp - +from toad.widget_view import ToadHosted class WelcomeTitle(SplashText): """The app's name, swept into the theme's colours each time the view shows.""" @@ -194,7 +190,7 @@ async def present_selected(self, event: SessionSelected) -> None: self.present() -class WelcomeScreen(TogglesIrc, ProjectSessionOrigin, SessionView, can_focus=False): +class WelcomeScreen(ToadHosted, TogglesIrc, ProjectSessionOrigin, SessionView, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" BINDINGS = [ @@ -218,7 +214,7 @@ def history_origin(self) -> WelcomeHistoryOrigin: def __init__(self) -> None: super().__init__() - app = cast("ToadApp", self.app) + app = self.app self.project_path = app.project_dir def compose(self) -> ComposeResult: @@ -247,7 +243,7 @@ def read_history_identity(self, project: Checkout) -> ThreadIdentity: ) async def prepare_history_origin(self) -> None: - app = cast("ToadApp", self.app) + app = self.app project = self.project_path with self.changing_identity(): self._identity = await app.preparation.run_thread(self.read_history_identity, project) diff --git a/src/toad/widget_view.py b/src/toad/widget_view.py index bf8888f13..67d7a4e5a 100644 --- a/src/toad/widget_view.py +++ b/src/toad/widget_view.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from toad.app import ToadApp from toad.screens.workspace import WorkspaceFrame from textual import events @@ -19,6 +20,18 @@ FILL = Scalar(1.0, Unit.FRACTION, Unit.WIDTH) +class ToadHosted: + """A node that lives in Toad's application: its `app` is a ToadApp. + + The declaration is for readers and type checkers; at run time `app` is the + node's own, so nothing is asserted or wrapped. + """ + + if TYPE_CHECKING: + @property + def app(self) -> ToadApp: ... + + class WorkspaceHosted: """A widget whose native screen supplies the workspace frame and histories.""" diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index a1eff054b..df23455b2 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -17,7 +17,7 @@ from agent_comms.native_arguments import ConfiguredBinary, InheritedArguments from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from agent_comms.ui_model.sidebar import ChannelRowModel from toad.widget_actions import ActivationKeys @@ -48,10 +48,10 @@ from toad.widgets.sidebar_viewport import SidebarHeader from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarPanelWidget, SidebarVisibilityObserver from toad.navigation_target import NavigationTarget +from toad.widget_view import ToadHosted if TYPE_CHECKING: from agent_comms.comms import Comms - from toad.app import ToadApp @@ -234,7 +234,7 @@ def on_click(self, event) -> None: self.open_selected() -class ThreadStatusRow(CommsRow): +class ThreadStatusRow(ToadHosted, CommsRow): """Thread rows show activity through their glyph and summary, not held ink.""" DEFAULT_CSS = "ThreadStatusRow { color: $text-muted; }" @@ -256,7 +256,7 @@ def busy(self) -> bool: def show(self, row: ThreadRowModel, *, pinned: bool = False) -> None: """Render the model with this app's pin and pending action.""" - app = cast("ToadApp", self.app) + app = self.app row = app.session_navigation.thread_row(row) action_status = app.thread_actions.pending.get(row.name) if (row, pinned, action_status) == (self.thread_row, self.pinned, self.action_status): @@ -292,7 +292,7 @@ def mode_name(self) -> str | None: return self.navigation_tree.open_views.get(self.target_name) def open_selected(self) -> None: - if self.mode_name is None or cast("ToadApp", self.app).session_navigation.session_details(self.mode_name) is None: + if self.mode_name is None or self.app.session_navigation.session_details(self.mode_name) is None: super().open_selected() else: self.navigation_tree.remember_row(self) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 113e1023f..b279c9880 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -45,7 +45,6 @@ from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.conversation_actions import ConversationAction, WindowAction -from toad.app import ToadApp from toad.block_navigation import ( BlockCursor, ContentNavigation, @@ -88,7 +87,7 @@ from toad.shell import Shell from toad.status_thread import StatusThread from toad.transcript_publication import BoundView, TranscriptPresentation, ViewBinding -from toad.widget_view import WorkspaceHosted +from toad.widget_view import ToadHosted, WorkspaceHosted from toad.widgets.cli_draft import CliDraft from toad.widgets.comms_menu import ContextMenu from toad.widgets.flash import Flash @@ -794,6 +793,7 @@ def watch_agent_binding(self, binding: AgentConnection) -> None: class Conversation( + ToadHosted, ConversationCommsHandlers, NativeMethodKeys, ConversationSessionBinding, @@ -812,7 +812,6 @@ def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: contents = getters.query_one("#contents", Contents) window = getters.query_one(Window) cursor = getters.query_one(CursorContainer) - app = getters.app(ToadApp) @property def visible_read_cursor(self): diff --git a/src/toad/widgets/flash.py b/src/toad/widgets/flash.py index 9f4c1dd67..981354cb9 100644 --- a/src/toad/widgets/flash.py +++ b/src/toad/widgets/flash.py @@ -4,13 +4,12 @@ from textual.reactive import var from textual.widgets import Static from textual.timer import Timer -from textual import getters -from toad.app import ToadApp +from toad.widget_view import ToadHosted -class Flash(Static): +class Flash(ToadHosted, Static): DEFAULT_CSS = """ Flash { height: 1; @@ -47,7 +46,6 @@ class Flash(Static): } } """ - app = getters.app(ToadApp) flash_timer: var[Timer | None] = var(None) def __init__(self, *args, **kwargs) -> None: diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index 1cbbf17e3..5d2ef8fc9 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -25,14 +25,13 @@ MutedColour, ) -from toad.app import ToadApp from toad.core import events as core_events from toad.core import session_requests from toad.core.events import SessionChangedEvent from toad.core_event_carrier import CoreEventReceiver from toad.frame_presentation import Animated from toad.session_tracker import OpenTab -from toad.widget_view import AUTO, FILL, WidgetView +from toad.widget_view import AUTO, FILL, ToadHosted, WidgetView from toad.widgets.selection import ChangingText, ViewLabel @@ -208,7 +207,7 @@ class TabCells(WidgetView, containers.VerticalGroup): placement_offset = ScalarOffset.from_offset((0, 1)) -class SessionsTabs(WidgetView, CoreEventReceiver, Widget): +class SessionsTabs(ToadHosted, WidgetView, CoreEventReceiver, Widget): DEFAULT_LAYOUT = VerticalLayout width = FILL height = Scalar.from_number(3) @@ -217,7 +216,6 @@ class SessionsTabs(WidgetView, CoreEventReceiver, Widget): vertical_scrollbar_size = 0 ALLOW_SELECT = False - app: getters.app[ToadApp] = getters.app(ToadApp) title_container = getters.query_one("#title-container", Widget) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index f6a3d6dc2..d0441199a 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -13,7 +13,7 @@ from toad.preferences import SETTINGS from dataclasses import dataclass import asyncio -from typing import TYPE_CHECKING, ClassVar, cast +from typing import TYPE_CHECKING, ClassVar from textual import containers, events, on, widgets from textual.app import ComposeResult @@ -38,9 +38,9 @@ SidebarShift, ) from toad.widgets.sidebar_viewport import SidebarHeader, SidebarViewport +from toad.widget_view import ToadHosted if TYPE_CHECKING: - from toad.app import ToadApp from toad.tab_order import TabOrder @@ -268,7 +268,7 @@ class HeaderButton(ActivationKeys, HoverSelection, widgets.Static, can_focus=Tru } """ -class TabHistoryButton(HeaderButton): +class TabHistoryButton(ToadHosted, HeaderButton): """Back or forward through visited tabs.""" direction: ClassVar[int] @@ -293,7 +293,7 @@ def primary_activation(self): return self.navigate def navigate(self) -> None: - cast("ToadApp", self.app).tab_order.navigate(self.direction) + self.app.tab_order.navigate(self.direction) class BackTabButton(TabHistoryButton): @@ -344,7 +344,7 @@ async def chosen(self, action: ApplicationAction | None) -> None: await invoke(action.apply, self.app) -class TabHistoryControls(CoreEventReceiver, containers.HorizontalGroup): +class TabHistoryControls(ToadHosted, CoreEventReceiver, containers.HorizontalGroup): """Left-sidebar controls shared by native thread and channel screens.""" DEFAULT_CSS = """ @@ -361,7 +361,7 @@ def compose(self) -> ComposeResult: yield ForwardTabButton() def on_mount(self) -> None: - app = cast("ToadApp", self.app) + app = self.app self.observe_core(app.tab_order.events) self.observe_core(app.events) self._sync() @@ -371,7 +371,7 @@ async def history_changed(self, event: core_events.TabHistoryChanged | core_even self._sync() def _sync(self) -> None: - app = cast("ToadApp", self.app) + app = self.app for button in self.query(TabHistoryButton): button.sync(app.tab_order) @@ -513,6 +513,7 @@ def activate(self) -> None: self.post_message(self.Pressed(self.action)) class SidebarResizeHandle( + ToadHosted, MovesHorizontalCursor, CapturedDrag, ColourSelection, widgets.Static, can_focus=True ): """Drag the conversation-facing edge; the placement model owns the width.""" @@ -538,7 +539,7 @@ def drag_to(self, screen_x: int) -> None: bar = self.query_ancestor(SideBar) if bar.id is None or bar.parent is None: return - app = cast("ToadApp", self.app) + 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)) @@ -578,7 +579,7 @@ async def apply(self, sidebar: SideBar) -> None: sidebar.search_input.focus() -class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, +class SideBar(ToadHosted, DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, containers.Vertical, actions=(SearchSidebarAction,)): tone = SidebarToneColour BINDING_GROUP_TITLE = "Sidebar" @@ -670,15 +671,15 @@ def panels_loaded(self) -> bool: @property def navigation(self) -> SidebarState: - return cast("ToadApp", self.app).sidebar_state + return self.app.sidebar_state def mount_collapse(self) -> None: - app = cast("ToadApp", self.app) + app = self.app self.observe_core(app.saved_settings.events) self.collapsed = SETTINGS.sidebar.hide.bind(app.saved_settings.state).value def store_collapsed(self, value: bool) -> None: - app = cast("ToadApp", self.app) + app = self.app app.saved_settings.set(SETTINGS.sidebar.hide, value) self.collapsed = SETTINGS.sidebar.hide.bind(app.saved_settings.state).value @@ -790,7 +791,7 @@ def on_show(self) -> None: self._present_controls() def _present_controls(self) -> bool: - app = cast("ToadApp", self.app) + app = self.app if not self.is_mounted: return False placement = app.sidebar_layout.get(self.id) @@ -868,7 +869,7 @@ def _present_controls(self) -> bool: def on_sidebar_slider(self, event: SidebarSlider.Changed) -> None: event.stop() if self.id is not None: - app = cast("ToadApp", self.app) + app = self.app if app.sidebar_layout.width(self.id, event.value): self._present_controls() app.workspace_chrome.refresh_layout() @@ -878,7 +879,7 @@ def on_sidebar_action(self, event: SidebarAction.Pressed) -> None: event.stop() if self.id is None: return - app = cast("ToadApp", self.app) + app = self.app event.action.apply(app.sidebar_layout, self.id) for bar in ( app.workspace_chrome.channels, @@ -893,7 +894,7 @@ def restore_navigation(self) -> bool: self.watch_collapsed(self.collapsed) else: self._present_controls() - cast("ToadApp", self.app).workspace_chrome.refresh_layout() + self.app.workspace_chrome.refresh_layout() navigation = self.navigation for panel in self.query(SideBarCollapsible): if str(panel.title) in navigation.panels_collapsed: @@ -924,7 +925,7 @@ def watch_collapsed(self, collapsed: bool) -> None: self._presented_layout = None if self.is_mounted: self._present_controls() - cast("ToadApp", self.app).workspace_chrome.refresh_layout() + self.app.workspace_chrome.refresh_layout() def render(self) -> str: return ("<" if self.right else ">") if self.collapsed else "" diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index d48c9cd0b..64319249a 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -5,14 +5,12 @@ from textual.app import ComposeResult from textual.await_complete import AwaitComplete -from textual import getters from textual.reactive import var from textual.css.query import NoMatches from textual import containers from textual.widget import Widget -from toad.app import ToadApp from toad.tool_output import ToolOutput, ToolOutputPart from metaclass_registry.mro_dispatch import handles from toad.acp.status import ToolCallStatus @@ -25,6 +23,7 @@ from toad.widgets.line_markdown import LineMarkdown from toad.widgets.message_divider import SingleLineBlock from toad.widgets.tool_content import ToolContent +from toad.widget_view import ToadHosted @@ -49,7 +48,7 @@ def toggle_details(self, link: ToggleDetails) -> None: self.app.bell() -class ToolCall(ConversationBlock, SnapshotPresentation, CategorizedBlock, containers.VerticalGroup): +class ToolCall(ToadHosted, ConversationBlock, SnapshotPresentation, CategorizedBlock, containers.VerticalGroup): DEFAULT_CSS = """ ToolCall { margin: 0 !important; @@ -80,7 +79,6 @@ class ToolCall(ConversationBlock, SnapshotPresentation, CategorizedBlock, contai def message_category(self) -> type[MessageCategory]: return ToolCategory - app = getters.app(ToadApp) has_content: var[bool] = var(False, toggle_class="-has-content") expanded: var[bool] = var(False, toggle_class="-expanded") tool_call: var[ToolCallStatus | None] = var(None) From bfb5851f73560db75113d45a4e661ecf6639178b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:39:01 -0400 Subject: [PATCH 06/14] Keep a tall arrival's head through its change; start a burst's fade with its reveal Review of 4c46986f found three defects, all fixed here: - A tall arrival snapped back to the tail. place_reader stores placed(), whose default calls moved(window, 0); inside the open change that is shifted(0), which returned TailPosition, so the next placement re-anchored the window. ArrivalPosition.placed now keeps what it placed: the message head as a FragmentPosition, or the tail. shifted() keeps the arrival; the reader leaving the tail replaces it through left_tail like any kept position. - An arrival hidden by the transcript filter resolved to the previous drawn message and could pull the reader up to it. hold_arrival takes the first arrival the page shows, and only for a shown window. - Only following hands the reader to an arrival: ReaderPosition.arrived keeps any kept position, TailPosition answers ArrivalPosition, and LatestPosition (End pressed, awaiting its source) keeps itself. - Covered rows never paint NEW_TEXT, which was what started it, so a burst could restart its reveal on every arrival while unfocused and keep rows blank. arrivals_began starts the fade with the reveal, so the burst is decided by a running phase. Cost: the reveal runs at NEW_TEXT's 30 fps, and a clock repaint with no new arrivals no longer scans every loaded fragment of every page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/widgets/history_anchor.py | 30 +++++++++++++++++++++++--- src/toad/widgets/transcript_history.py | 25 ++++++++++++++------- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index 69f46c315..3201dc649 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -152,6 +152,10 @@ def joined_tail(self) -> "ReaderPosition": """The position after the reader chose to follow the latest message.""" return TailPosition() + def arrived(self, page: TranscriptPageView, key: FragmentKey) -> "ReaderPosition": + """The position after a message arrived at the followed end: only following hands it the reader.""" + return self + def placed(self, window: "HistoryWindow", reached: bool) -> "ReaderPosition": """What the window keeps after a layout placed this: itself while the history is still changing. @@ -214,6 +218,9 @@ def shifted(self, rows: float) -> ReaderPosition: def joined_tail(self) -> ReaderPosition: return self + def arrived(self, page: TranscriptPageView, key: FragmentKey) -> ReaderPosition: + return ArrivalPosition(page, key) + class OffsetPosition(ReaderPosition): """A position inside the history, placed by its destination in the current layout.""" @@ -356,14 +363,27 @@ def laid_out(self, window: HistoryWindow) -> bool: def destination(self, window: "HistoryWindow") -> float: return self.location().destination(window, 0.0) + def tall(self, window: "HistoryWindow") -> bool: + """Whether the message is taller than the window, in the layout just made.""" + return window.message_head_hidden(self.page.drawn_height(self.location().location().fragment), None) + def place(self, window: "HistoryWindow") -> bool: - if window.message_head_hidden(self.page.drawn_height(self.location().location().fragment), None): + if self.tall(window): return super().place(window) return TailPosition().place(window) + def placed(self, window: "HistoryWindow", reached: bool) -> ReaderPosition: + """The rest of the change keeps the head (as a fragment position) or the tail it was placed on. + + The reader leaving the tail (left_tail) replaces it like any kept position. + """ + if self.tall(window): + span = self.location().location() + return FragmentPosition(span.fragment.key, 0.0, span.height, block=span.index).placed(window, reached) + return TailPosition().placed(window, reached) + def shifted(self, rows: float) -> ReaderPosition: - # The reader moved while it arrived: their movement decides, not the arrival. - return TailPosition() + return self @dataclass(frozen=True) @@ -510,6 +530,10 @@ def release(self, window: "HistoryWindow") -> None: window.reader_position = self.released() window.place_reader() + def arrived(self, page: TranscriptPageView, key: FragmentKey) -> ReaderPosition: + # End was pressed: the newest row stays the destination until its source settles. + return self + def released(self) -> ReaderPosition: return TailPosition() diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index a62ea6276..478c833ff 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -70,7 +70,6 @@ SnapshotPresentation, ) from toad.widgets.history_anchor import ( - ArrivalPosition, FragmentLocation, FragmentNotLoaded, FragmentPosition, @@ -116,7 +115,8 @@ # An arriving message is uncovered from its first row down, alongside NEW_TEXT's fade. -ARRIVAL_REVEAL = Transition(Amount, 0.0, 1.0, duration=NEW_TEXT.duration, frames_per_second=60) +ARRIVAL_REVEAL = Transition(Amount, 0.0, 1.0, duration=NEW_TEXT.duration, + frames_per_second=NEW_TEXT.frames_per_second) class HistoryEdge(Activates, Static, can_focus=True): @@ -989,13 +989,21 @@ def revealed_rows(self) -> float: def arrivals_began(self) -> None: """A burst of arrivals uncovers each message from its first row down, one window height in one sweep.""" self.reveal_rows = self.window.scrollable_content_region.height + # The fade starts with the reveal: covered rows never paint it, and + # the burst is decided by the fade's phase. + self.run_effect(NEW_TEXT) self.run_effect(ARRIVAL_REVEAL) def hold_arrival(self, arrivals: tuple[TranscriptFragment, ...]) -> None: - """The window places the first arriving message: from its head when it is taller than the window.""" - if arrivals: - head = arrivals[0] - self.window.reader_position = ArrivalPosition(self.page_holding(head.key), head.key) + """The first shown arriving message places a following reader: from its head when it is taller than the window.""" + window = self.window + if not window.shown: + return + for head in arrivals: + page = self.page_holding(head.key) + if head.shown_in(page.visible_categories): + window.reader_position = window.reader_position.arrived(page, head.key) + return def repaint(self, fragments: tuple[TranscriptFragment, ...] = (), *, continuing=()) -> None: phase = self.effect_phase(NEW_TEXT) @@ -1005,8 +1013,9 @@ def repaint(self, fragments: tuple[TranscriptFragment, ...] = (), *, continuing= for page in self.pages: previous = bool(page.arrivals) page.arrivals.intersection_update(phase.keeps_arrivals(page.arrivals)) - page.arrivals.update(id(fragment) for fragment in page.prepared.fragments_in_range - if fragment.arrival_position in positions) + if positions: + page.arrivals.update(id(fragment) for fragment in page.prepared.fragments_in_range + if fragment.arrival_position in positions) if previous or page.arrivals: page.refresh() From a0fe52b9ffe8757735c774f087fe5abdc5db33ad Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:44:54 -0400 Subject: [PATCH 07/14] Keep the arrival position for its whole change; uncover nothing off screen Second review pass risks, both closed: - ArrivalPosition no longer turns into a FragmentPosition after its first placement. A FragmentPosition finds its page only once that page has been published, so a later layout in the same change could move the rows above the head with nothing placing it. The arrival keeps itself (shifted returns it) until its change ends, so every layout of the change places it from its own page; the inherited placed() then reads where the reader is. - A burst that begins while the history is off screen starts no fade or reveal: queued, they would start at the first paint and cover rows the reader already saw. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/widgets/history_anchor.py | 13 +++---------- src/toad/widgets/transcript_history.py | 2 ++ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index 3201dc649..2e09e8a32 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -372,17 +372,10 @@ def place(self, window: "HistoryWindow") -> bool: return super().place(window) return TailPosition().place(window) - def placed(self, window: "HistoryWindow", reached: bool) -> ReaderPosition: - """The rest of the change keeps the head (as a fragment position) or the tail it was placed on. - - The reader leaving the tail (left_tail) replaces it like any kept position. - """ - if self.tall(window): - span = self.location().location() - return FragmentPosition(span.fragment.key, 0.0, span.height, block=span.index).placed(window, reached) - return TailPosition().placed(window, reached) - def shifted(self, rows: float) -> ReaderPosition: + # Kept through its change, so every layout of the change places it + # from its own page; afterwards the window reads where the reader is. + # The reader leaving the tail replaces it through left_tail. return self diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 478c833ff..ba7afc168 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -988,6 +988,8 @@ def revealed_rows(self) -> float: def arrivals_began(self) -> None: """A burst of arrivals uncovers each message from its first row down, one window height in one sweep.""" + if not self.is_on_screen: + return # A burst nobody sees has nothing to uncover. self.reveal_rows = self.window.scrollable_content_region.height # The fade starts with the reveal: covered rows never paint it, and # the burst is decided by the fade's phase. From d4a91fa87a1260d572cba3eb78b86df48deffa6d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:50:15 -0400 Subject: [PATCH 08/14] Draw the switch toast's name three rows tall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast drew the name at body size, which is not the glance-readable name the toast exists for. The name is now drawn three rows tall (LargeText) when it fits the screen with the toast's margins, and at normal size otherwise. Textual owns a 3x3 box-drawing font only for digits, A-F and a few signs (textual/renderables/digits.py: DIGITS, DIGITS3X3); Rich and the installed packages have no letter font. LargeText reads Textual's glyphs for those characters and declares only the letters and signs Textual lacks, in the same light style; lower case draws as upper case, anything else at normal size on the middle row. The right-edge offset in the earlier screenshots was the rasterizer, not the toast: every row of the toast strip is exactly the box width with the border in its first and last cells (probe: widths {69}, right column ╮│││││╯), and the SVG places the right border at the corner's column. rsvg-convert ignores the SVG's textLength and falls back to a font whose box glyphs advance narrower; Chromium renders the same SVG aligned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/large_text.py | 71 ++++++++++++++++++++++++++++++++ src/toad/widgets/switch_toast.py | 20 ++++++--- 2 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 src/toad/large_text.py diff --git a/src/toad/large_text.py b/src/toad/large_text.py new file mode 100644 index 000000000..25449e34e --- /dev/null +++ b/src/toad/large_text.py @@ -0,0 +1,71 @@ +"""Text drawn three rows tall in box-drawing glyphs, for a name read at a glance. + +Textual's own 3x3 font (`textual.renderables.digits`) owns the digits, `A`-`F` +and a few signs; this module adds the letters and signs it lacks, in the same +light style. Lower case is drawn as upper case. A character neither table +has is drawn at normal size on the middle row. +""" + +from __future__ import annotations + +from typing import ClassVar + +from rich.cells import cell_len +from textual.renderables.digits import DIGITS, DIGITS3X3 + +Glyph = tuple[str, str, str] + + +def _textual_glyphs() -> dict[str, Glyph]: + """Textual's glyphs, each padded to its three-cell width.""" + return { + character: tuple(row.ljust(3) for row in DIGITS3X3[index * 3:index * 3 + 3]) # type: ignore[misc] + for index, character in enumerate(DIGITS) + } + + +class LargeText: + """A line of text three rows tall, with a column between glyphs.""" + + HEIGHT: ClassVar[int] = 3 + GLYPHS: ClassVar[dict[str, Glyph]] = { + **_textual_glyphs(), + "G": ("╭─╴", "│╶╮", "╰─╯"), + "H": ("╷ ╷", "├─┤", "╵ ╵"), + "I": ("╶┬╴", " │ ", "╶┴╴"), + "J": (" ╷", " │", "╰─╯"), + "K": ("╷╭╯", "├┤ ", "╵╰╮"), + "L": ("╷ ", "│ ", "╰─╴"), + "M": ("╭┬╮", "│││", "╵╵╵"), + "N": ("╭─╮", "│ │", "╵ ╵"), + "O": ("╭─╮", "│ │", "╰─╯"), + "P": ("┌─╮", "├─╯", "╵ "), + "Q": ("╭─╮", "│ │", "╰─╳"), + "R": ("┌─╮", "├┬╯", "╵╰╴"), + "S": ("╭─╴", "╰─╮", "╶─╯"), + "T": ("╶┬╴", " │ ", " ╵ "), + "U": ("╷ ╷", "│ │", "╰─╯"), + "V": ("╷ ╷", "╲ ╱", " ╹ "), + "W": ("╷ ╷", "│││", "╰┴╯"), + "X": ("╲ ╱", " ╳ ", "╱ ╲"), + "Y": ("╷ ╷", "╰┬╯", " ╵ "), + "Z": ("╶─╮", " ╱ ", "╰─╴"), + ".": (" ", " ", "•"), + "·": (" ", "•", " "), + "_": (" ", " ", "───"), + "/": (" ╱", " ╱ ", "╱ "), + "#": ("╪╪", "╪╪", " "), + } + + @classmethod + def glyph(cls, character: str) -> Glyph: + return cls.GLYPHS.get(character.upper(), (" " * cell_len(character), character, " " * cell_len(character))) + + @classmethod + def rows(cls, text: str) -> tuple[str, ...]: + glyphs = [cls.glyph(character) for character in text] + return tuple(" ".join(glyph[row] for glyph in glyphs) for row in range(cls.HEIGHT)) + + @classmethod + def width(cls, text: str) -> int: + return cell_len(cls.rows(text)[0]) diff --git a/src/toad/widgets/switch_toast.py b/src/toad/widgets/switch_toast.py index c765dc354..fd67918e4 100644 --- a/src/toad/widgets/switch_toast.py +++ b/src/toad/widgets/switch_toast.py @@ -18,6 +18,7 @@ from toad.core import events as core_events from toad.core_event_carrier import CoreEventReceiver from toad.frame_presentation import Animated +from toad.large_text import LargeText from toad.widget_view import WidgetView if TYPE_CHECKING: @@ -68,7 +69,8 @@ class SwitchToast(CoreEventReceiver, WidgetView, Animated[Amount]): def __init__(self) -> None: super().__init__() - self.title = "" + # The name as drawn: three rows tall when that fits the screen, else one. + self.lines: tuple[str, ...] = () self.display = False def on_mount(self) -> None: @@ -83,11 +85,18 @@ def session_selected(self, event: core_events.SessionSelected) -> None: @property def box(self) -> tuple[int, int]: rows, columns = self.MARGIN - width = min(cell_len(self.title) + 2 * columns + 2, max(0, self.screen.size.width - 4)) - return width, 2 * rows + 3 + width = min(max(map(cell_len, self.lines)) + 2 * columns + 2, self.room) + return width, len(self.lines) + 2 * rows + 2 + + @property + def room(self) -> int: + """The widest the toast may be: the screen less a margin each side.""" + return max(0, self.screen.size.width - 4) def show(self, title: str) -> None: - self.title = title + large = LargeText.rows(title) + fits = LargeText.width(title) + 2 * self.MARGIN[1] + 2 <= self.room + self.lines = large if fits else (title,) width, height = self.box self.width, self.height = Scalar.from_number(width), Scalar.from_number(height) screen = self.screen.size @@ -117,7 +126,8 @@ def render_line(self, y: int, paint: PaintState) -> Strip: if y == 0 or y == height - 1: left, right = ("╭", "╮") if y == 0 else ("╰", "╯") return Strip([Segment(left + "─" * inner + right, frame)], width) - text = self.title if y == height // 2 else "" + line = y - 1 - self.MARGIN[0] + text = self.lines[line] if 0 <= line < len(self.lines) else "" if cell_len(text) > inner: text = set_cell_size(text, max(0, inner - 1)) + "…" padding = inner - cell_len(text) From 5aaf606188729a839a3148a8afecb97efbc8a138 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:59:30 -0400 Subject: [PATCH 09/14] Keep F1 row reads on the screen captured keymap catalog --- src/toad/keymap.py | 7 ------- src/toad/screens/keymap.py | 18 +++++++++--------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/toad/keymap.py b/src/toad/keymap.py index 642acb12f..d6deb5b75 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -75,13 +75,6 @@ def of(cls, invocation: KeyInvocation, mode: type[KeyMode]) -> KeymapRow: def binding_id(self) -> str: return KeyInvocation.in_mode(self.declaration, self.mode().binding_mode) - @property - def defaults(self) -> tuple[str, ...]: - return KeymapCatalog.capture((self.mode,)).defaults(self) - - def effective(self, keymap: Keymap) -> tuple[str, ...]: - return keymap.keys_for(self, self.defaults) - @dataclass(frozen=True) class KeyRemap(KeymapRow): diff --git a/src/toad/screens/keymap.py b/src/toad/screens/keymap.py index 4489ee037..1bc27b94a 100644 --- a/src/toad/screens/keymap.py +++ b/src/toad/screens/keymap.py @@ -40,8 +40,9 @@ def __init__(self, focus_mode: type[EditingMode], focused: frozenset[str]) -> No self.focus_mode, self.focused_ids = focus_mode, focused self.mode_choices = FamilyChoices(KeyMode) self.modes = self.mode_choices.choices() + self.catalog = KeymapCatalog.capture(self.modes) self.declarations = tuple(reference for reference, invocation - in KeymapCatalog.capture(self.modes).invocations.items() + in self.catalog.invocations.items() if not invocation.key_system()) self.visible_declarations = self.declarations @@ -68,18 +69,17 @@ def populate(self, term: str) -> None: table = self.query_one(DataTable) table.clear() focused_only = self.query_one("#keymap-focused", Checkbox).value - catalog = KeymapCatalog.capture(self.modes) self.visible_declarations = tuple(declaration for declaration in self.declarations if (not focused_only or KeyInvocation.in_mode(declaration, self.focus_mode) in self.focused_ids) - and term.casefold() in (catalog.require(declaration).key_title() + " " - + catalog.require(declaration).key_summary()).casefold()) + and term.casefold() in (self.catalog.require(declaration).key_title() + " " + + self.catalog.require(declaration).key_summary()).casefold()) keymap = self.app.settings.ui.keymap conflicts = {row.binding_id for mode in self.modes for conflict in keymap.conflicts(mode) for row in (conflict.first, conflict.second)} for declaration in self.visible_declarations: - title = Text(catalog.require(declaration).key_title(), + title = Text(self.catalog.require(declaration).key_title(), style="" if KeyInvocation.in_mode(declaration, self.focus_mode) in self.focused_ids else "dim") - table.add_row(title, *(Text(", ".join(catalog.effective(row, keymap)) or "—", + table.add_row(title, *(Text(", ".join(self.catalog.effective(row, keymap)) or "—", style="bold red" if row.binding_id in conflicts else "") for mode in self.modes for row in (KeymapRow(declaration, mode),))) @@ -96,16 +96,16 @@ def selected_rows(self) -> tuple[KeymapRow, ...]: row, column = self.query_one(DataTable).cursor_coordinate if column and row < len(self.visible_declarations): selected = KeymapRow(self.visible_declarations[row], self.modes[column - 1]) - return (selected,) if selected.defaults else () + return (selected,) if self.catalog.defaults(selected) else () return () @on(DataTable.CellSelected) def picked(self, message: DataTable.CellSelected) -> None: for row in self.selected_rows(): self.query_one("#keymap-keys", Input).value = ", ".join( - row.effective(self.app.settings.ui.keymap)) + self.catalog.effective(row, self.app.settings.ui.keymap)) self.query_one("#keymap-summary", Static).update( - KeymapCatalog.capture((row.mode,)).require(row.declaration).key_summary()) + self.catalog.require(row.declaration).key_summary()) conflicts = tuple(conflict for conflict in self.app.settings.ui.keymap.conflicts(row.mode) if row in (conflict.first, conflict.second)) self.query_one("#keymap-status", Static).update("; ".join(map(str, conflicts))) From 83d0fd15d0515cc18434f431429d9b070dca6305 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:01:16 -0400 Subject: [PATCH 10/14] Use the existing ToadNode, alternate listed rows by family, clear new ratchet debt The debt ratchet on a0fe52b9 failed on debt this branch introduced: - ToadHosted duplicated ToadNode (app_node.py), which already declares `app: ToadApp` and which DeclaredWidgetActions, SessionScope and others use. Every class now inherits ToadNode and ToadHosted is deleted; ToadNode sits last in each base list so the MRO stays consistent. This also clears the mypy errors the typing-only property caused. - AlternatingTone declared None in its tone pair. Rows now name both tones: ListedRow (a row its list orders) alternates on SidebarToneColour and AlternateRowToneColour; ChannelRow, the channel rows the welcome screen lists, alternates on the channel hue pair; the channel group header keeps ChannelHeader. A channel's title row inside its header is no longer an alternating row at all, so it never paints over the header's tone. SidebarGroup's rows are bound to ListedRow, the family that can be placed. - ThreadRow.open_selected spelled absence as None twice through a mode_name property; it now asks whether the open view's mode has a navigation entry whose session is tracked, which is what session_details answered. - selected_span and the toast's tab lookup no longer test for None. - The toast is a plain widget (CSS position/overlay, styles for its size), not a WidgetView, and reads the app through ToadNode. - effect_frame is generic per effect, so a history reads its Amount reveal and its Mix fade through one method. - New quoted annotations in history_anchor.py are unquoted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/frame_presentation.py | 2 +- src/toad/large_text.py | 11 ++++-- src/toad/navigation_target.py | 6 ++-- src/toad/screens/action_modal.py | 4 +-- src/toad/screens/agent_modal.py | 4 +-- src/toad/screens/comms.py | 4 +-- src/toad/screens/main.py | 4 +-- src/toad/screens/settings.py | 6 ++-- src/toad/screens/store.py | 8 ++--- src/toad/screens/welcome.py | 10 +++--- src/toad/tone_paint.py | 12 ++++--- src/toad/widget_view.py | 13 ------- src/toad/widgets/comms_sidebar.py | 33 ++++++++++++------ src/toad/widgets/conversation.py | 6 ++-- src/toad/widgets/flash.py | 4 +-- src/toad/widgets/history_anchor.py | 10 +++--- src/toad/widgets/session_tabs.py | 15 ++++---- src/toad/widgets/side_bar.py | 13 ++++--- src/toad/widgets/sidebar_tree.py | 4 +-- src/toad/widgets/switch_toast.py | 56 ++++++++++++++++++++---------- src/toad/widgets/tool_call.py | 4 +-- 21 files changed, 127 insertions(+), 102 deletions(-) diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index 9f630d986..a8f17c4db 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -277,7 +277,7 @@ def stop_effect(self, *effects: Effect) -> None: if animation := self.effects.pop(effect, None): animation.stop() - def effect_frame(self, effect: Effect[FrameT]) -> FrameT: + def effect_frame[Shown: Frame](self, effect: Effect[Shown]) -> Shown: animation = self.effects.get(effect) return effect.final if animation is None else animation.frame() diff --git a/src/toad/large_text.py b/src/toad/large_text.py index 25449e34e..e59729a51 100644 --- a/src/toad/large_text.py +++ b/src/toad/large_text.py @@ -19,7 +19,7 @@ def _textual_glyphs() -> dict[str, Glyph]: """Textual's glyphs, each padded to its three-cell width.""" return { - character: tuple(row.ljust(3) for row in DIGITS3X3[index * 3:index * 3 + 3]) # type: ignore[misc] + character: tuple(row.ljust(3) for row in DIGITS3X3[index * 3 : index * 3 + 3]) # type: ignore[misc] for index, character in enumerate(DIGITS) } @@ -59,12 +59,17 @@ class LargeText: @classmethod def glyph(cls, character: str) -> Glyph: - return cls.GLYPHS.get(character.upper(), (" " * cell_len(character), character, " " * cell_len(character))) + return cls.GLYPHS.get( + character.upper(), + (" " * cell_len(character), character, " " * cell_len(character)), + ) @classmethod def rows(cls, text: str) -> tuple[str, ...]: glyphs = [cls.glyph(character) for character in text] - return tuple(" ".join(glyph[row] for glyph in glyphs) for row in range(cls.HEIGHT)) + return tuple( + " ".join(glyph[row] for glyph in glyphs) for row in range(cls.HEIGHT) + ) @classmethod def width(cls, text: str) -> int: diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 2599918f0..1a4eb6769 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from agent_comms.display_references import CurrentDisplayReferences - from toad.widgets.comms_sidebar import CommsRow + from toad.widgets.comms_sidebar import CommsRow, ListedRow from toad.widgets.sidebar_tree import SidebarGroup @dataclass(frozen=True) @@ -55,7 +55,7 @@ def matches(self, target: NavigationTarget) -> bool: """Different opening behaviors may display the same sidebar address.""" return self.name == target.name - def rows_in(self, group: SidebarGroup[CommsRow]) -> tuple[CommsRow, ...]: + def rows_in(self, group: SidebarGroup[ListedRow]) -> tuple[CommsRow, ...]: return group.rows_for_target(self.name) @abstractmethod @@ -73,7 +73,7 @@ def selected(self, owner: NavigationOwner) -> None: def matches(self, target: NavigationTarget) -> bool: return False - def rows_in(self, group: SidebarGroup[CommsRow]) -> tuple[CommsRow, ...]: + def rows_in(self, group: SidebarGroup[ListedRow]) -> tuple[CommsRow, ...]: return () async def open(self, context: NavigationContext) -> str: diff --git a/src/toad/screens/action_modal.py b/src/toad/screens/action_modal.py index 0ab904374..ee6fa09b2 100644 --- a/src/toad/screens/action_modal.py +++ b/src/toad/screens/action_modal.py @@ -17,12 +17,12 @@ from toad.core.source_events import CommandComplete from toad.core_event_carrier import CoreEventReceiver from metaclass_registry.mro_dispatch import handles -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh" -class ActionModal(ToadHosted, CoreEventReceiver, DismissibleScreen[int]): +class ActionModal(CoreEventReceiver, DismissibleScreen[int], ToadNode): """Executes an action command.""" CSS_PATH = "store.tcss" diff --git a/src/toad/screens/agent_modal.py b/src/toad/screens/agent_modal.py index 3d135d6c9..16ed32ef5 100644 --- a/src/toad/screens/agent_modal.py +++ b/src/toad/screens/agent_modal.py @@ -18,7 +18,7 @@ from toad.core.session_requests import LaunchAgent from toad.preferences import SETTINGS from toad.platform import host -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode class DescriptionContainer(containers.VerticalScroll): @@ -28,8 +28,8 @@ def allow_focus(self) -> bool: class AgentModal( - ToadHosted, PriorityActions, HiddenActions, Launches, DismissibleScreen[LaunchAgent | None], + ToadNode, scopes={PriorityActions: (widget_actions.LaunchAction,), HiddenActions: (widget_actions.DismissAction,)}, ): diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 7846f8ac7..4a5ba97c4 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -31,7 +31,7 @@ from toad.widgets.session_tabs import SessionsTabs from toad.widgets.side_bar import SideBar, ThreadSidebar, TabHistoryControls from toad.widgets.thread_comms import RelationshipSort, ThreadCommsSidebar -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode class ViewAction(WidgetAction, affix="Action"): @@ -79,7 +79,7 @@ def apply(self, context: CommsScreen) -> None: chat.prepare_prompt() -class CommsScreen(ToadHosted, TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, can_focus=False, +class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, ToadNode, can_focus=False, actions=(ViewAction, ReturnToViewAction, CloseViewAction)): """A channel or DM represented as a native concurrent Toad session.""" diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 1908bca40..c7bbfb287 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -59,7 +59,7 @@ from toad.widgets.session_thread_sidebar import SessionThreadSidebar from toad.widgets.side_bar import SideBar from toad.widgets.splash_text import ThreadLoading -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode class SessionCommands(Provider): @@ -210,7 +210,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(ToadHosted, NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, +class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, ToadNode, scopes={HiddenActions: (SessionNavigationAction,)}, can_focus=False): footer_compact = True diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index c5486af57..215a5f1e1 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -21,10 +21,10 @@ from toad.widget_actions import PriorityActions from toad.widget_view import WidgetView from toad.setting_choices import SchemeName -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode -class SchemeEditor(ToadHosted, SelectEditor): +class SchemeEditor(SelectEditor, ToadNode): """A colour scheme's name, picked from the schemes the running app has.""" shows = (SchemeName,) @@ -52,7 +52,7 @@ class SettingsSearch(WidgetView, containers.VerticalGroup): padding = Spacing(1, 0, 1, 0) -class SettingsScreen(ToadHosted, PriorityActions, FormModal[None], DismissibleScreen[None]): +class SettingsScreen(PriorityActions, FormModal[None], DismissibleScreen[None], ToadNode): align_horizontal = "right" align_vertical = "top" diff --git a/src/toad/screens/store.py b/src/toad/screens/store.py index f1d190d0a..eddbebba1 100644 --- a/src/toad/screens/store.py +++ b/src/toad/screens/store.py @@ -38,7 +38,7 @@ from toad.widgets.grid_select import GridSelect from toad.agent_schema import AgentDefinition from toad.agents import AgentReadError, read_agents -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode QR = """\ @@ -146,7 +146,7 @@ def compose(self) -> ComposeResult: yield widgets.Static(agent.description, id="description") -class LauncherGridSelect(ToadHosted, Launches, CoreEventReceiver, GridSelect): +class LauncherGridSelect(Launches, CoreEventReceiver, GridSelect, ToadNode): HELP = """\ ## Launcher @@ -188,7 +188,7 @@ async def launch(self) -> None: self.app.session_navigation.events.publish(session_requests.LaunchAgent(child.agent.identity)) -class Launcher(ToadHosted, containers.VerticalGroup): +class Launcher(containers.VerticalGroup, ToadNode): grid_select = getters.query_one("#launcher-grid-select", LauncherGridSelect) DIGITS = "123456789ABCDEF" @@ -331,7 +331,7 @@ async def apply(self, store: StoreScreen) -> None: await launcher.launch_highlighted() -class StoreScreen(ToadHosted, ProfileKeys, CyclesFocus, CoreEventReceiver, Screen, actions=(LaunchSlotAction,)): +class StoreScreen(ProfileKeys, CyclesFocus, CoreEventReceiver, Screen, ToadNode, actions=(LaunchSlotAction,)): BINDING_GROUP_TITLE = "Screen" CSS_PATH = "store.tcss" diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 83a758702..621beb2af 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -29,7 +29,7 @@ from toad.session_scope import ProjectSessionOrigin from toad.tab_identity.history_origin import WelcomeHistoryOrigin from toad.thread_identity import ThreadIdentity -from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow +from toad.widgets.comms_sidebar import ChannelRow, CommsRow, ListedRow, NewSessionButton, ThreadStatusRow from toad.application_actions import NewThreadAction from toad.widgets.sidebar_tree import ( RowReconciliation, @@ -39,7 +39,7 @@ TargetTree, ) from toad.widgets.splash_text import SplashText -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode class WelcomeTitle(SplashText): """The app's name, swept into the theme's colours each time the view shows.""" @@ -74,7 +74,7 @@ def focus_changed(self, focused: bool) -> None: self.refresh() -class WelcomeSection(SidebarGroup[CommsRow]): +class WelcomeSection(SidebarGroup[ListedRow]): """Some of the sidebar model's rows under a title.""" TITLE = "" @@ -114,7 +114,7 @@ def keys(self, model): return tuple(model.channels.rows) def new_row(self, key): - return CommsRow(channel_target(key), key) + return ChannelRow(channel_target(key), key) def show_member(self, key, row): channel = self.query_ancestor(WelcomeEntries).model_state.shown.channels.rows[ @@ -190,7 +190,7 @@ async def present_selected(self, event: SessionSelected) -> None: self.present() -class WelcomeScreen(ToadHosted, TogglesIrc, ProjectSessionOrigin, SessionView, can_focus=False): +class WelcomeScreen(TogglesIrc, ProjectSessionOrigin, SessionView, ToadNode, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" BINDINGS = [ diff --git a/src/toad/tone_paint.py b/src/toad/tone_paint.py index 8dfaca056..dcc6f4196 100644 --- a/src/toad/tone_paint.py +++ b/src/toad/tone_paint.py @@ -6,7 +6,8 @@ from textual._paint_state import PaintLayer from textual.color import TRANSPARENT, Color -from ui_theme.scheme import AlternateRowToneColour, BackgroundColour, ThemeColour +from textual.widget import Widget +from ui_theme.scheme import AlternateRowToneColour, BackgroundColour, SidebarToneColour, ThemeColour if TYPE_CHECKING: from textual.css.styles import StylesBase @@ -70,18 +71,19 @@ class UntintedTonePaint(TonePaint): background_tint = TRANSPARENT -class AlternatingTone(TonePaint): +class AlternatingTone(TonePaint, Widget): """A list row on one of two tones by its position: rows separate without separator lines. A row family declares its own pair; the list that orders rows places them. """ - row_tones: ClassVar[tuple[type[ThemeColour] | None, type[ThemeColour] | None]] = ( - None, AlternateRowToneColour) + row_tones: ClassVar[tuple[type[ThemeColour], type[ThemeColour]]] = ( + SidebarToneColour, AlternateRowToneColour) _alternate = False + # TonePaint declares a class tone; a listed row computes its own from its place. @property - def tone(self) -> type[ThemeColour] | None: + def tone(self) -> type[ThemeColour]: # type: ignore[override] return self.row_tones[self._alternate] def place(self, index: int) -> None: diff --git a/src/toad/widget_view.py b/src/toad/widget_view.py index 67d7a4e5a..bf8888f13 100644 --- a/src/toad/widget_view.py +++ b/src/toad/widget_view.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from toad.app import ToadApp from toad.screens.workspace import WorkspaceFrame from textual import events @@ -20,18 +19,6 @@ FILL = Scalar(1.0, Unit.FRACTION, Unit.WIDTH) -class ToadHosted: - """A node that lives in Toad's application: its `app` is a ToadApp. - - The declaration is for readers and type checkers; at run time `app` is the - node's own, so nothing is asserted or wrapped. - """ - - if TYPE_CHECKING: - @property - def app(self) -> ToadApp: ... - - class WorkspaceHosted: """A widget whose native screen supplies the workspace frame and histories.""" diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index df23455b2..26f383f6a 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -48,7 +48,7 @@ from toad.widgets.sidebar_viewport import SidebarHeader from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarPanelWidget, SidebarVisibilityObserver from toad.navigation_target import NavigationTarget -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode if TYPE_CHECKING: from agent_comms.comms import Comms @@ -84,6 +84,7 @@ class ChannelGroup(SidebarGroup["ThreadRow"]): """A view of one channel row of the sidebar model and the thread rows it lists.""" navigation_header = True header_type = ChannelHeader + header: ChannelHeader def __init__(self, row: CommsRow, *, expanded: bool): self.channel: ChannelRowModel = UnpaintedChannel(row.target_name) self.sort_control = SessionSort(channel=row.target_name) @@ -163,7 +164,7 @@ def repaint_members(self, names: frozenset[str]) -> None: class CommsRow( - TargetTreeMember, OpensSelected, PrimaryActivation, CoreEventReceiver, AlternatingTone, HoverSelection, ViewLabel, + TargetTreeMember, OpensSelected, PrimaryActivation, CoreEventReceiver, HoverSelection, ViewLabel, scopes={HiddenActions: (CursorAction, OpenSelectedAction)}, ): """One interactive row: a channel or a thread.""" @@ -234,7 +235,17 @@ def on_click(self, event) -> None: self.open_selected() -class ThreadStatusRow(ToadHosted, CommsRow): +class ListedRow(CommsRow, AlternatingTone): + """A row its list orders: it alternates on its family's tone pair.""" + + +class ChannelRow(ListedRow): + """A channel listed as a row (not a group header): channels alternate on their hue pair.""" + + row_tones = (ChannelRowToneColour, ChannelAlternateRowToneColour) + + +class ThreadStatusRow(ListedRow, ToadNode): """Thread rows show activity through their glyph and summary, not held ink.""" DEFAULT_CSS = "ThreadStatusRow { color: $text-muted; }" @@ -287,16 +298,16 @@ def retire_thread_row(self) -> None: class ThreadRow(ThreadStatusRow): """A retained wire thread row, with an optional currently open native view.""" - @property - def mode_name(self) -> str | None: - return self.navigation_tree.open_views.get(self.target_name) - def open_selected(self) -> None: - if self.mode_name is None or self.app.session_navigation.session_details(self.mode_name) is None: - super().open_selected() - else: + """Select the tab that already shows this thread; open the thread when none does.""" + app = self.app + entries = {entry.mode: entry for entry in app.session_navigation.members} + mode = self.navigation_tree.open_views.get(self.target_name, "") + if mode in entries and entries[mode].identity in app.session_tracker.sessions: self.navigation_tree.remember_row(self) - self.app.select_session(self.mode_name) + app.select_session(mode) + else: + super().open_selected() class NewSessionButton(HoverSelection, ActivationKeys, TargetTreeMember, Static): diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index b279c9880..d23f3961a 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -87,7 +87,8 @@ from toad.shell import Shell from toad.status_thread import StatusThread from toad.transcript_publication import BoundView, TranscriptPresentation, ViewBinding -from toad.widget_view import ToadHosted, WorkspaceHosted +from toad.app_node import ToadNode +from toad.widget_view import WorkspaceHosted from toad.widgets.cli_draft import CliDraft from toad.widgets.comms_menu import ContextMenu from toad.widgets.flash import Flash @@ -793,10 +794,9 @@ def watch_agent_binding(self, binding: AgentConnection) -> None: class Conversation( - ToadHosted, ConversationCommsHandlers, NativeMethodKeys, - ConversationSessionBinding, + ConversationSessionBinding, ToadNode, actions=(ConversationAction,), ): """Holds the agent conversation (input, output, and various controls / information).""" diff --git a/src/toad/widgets/flash.py b/src/toad/widgets/flash.py index 981354cb9..98e94b20c 100644 --- a/src/toad/widgets/flash.py +++ b/src/toad/widgets/flash.py @@ -6,10 +6,10 @@ from textual.timer import Timer -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode -class Flash(ToadHosted, Static): +class Flash(Static, ToadNode): DEFAULT_CSS = """ Flash { height: 1; diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index 2e09e8a32..9a0a34c46 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -152,7 +152,7 @@ def joined_tail(self) -> "ReaderPosition": """The position after the reader chose to follow the latest message.""" return TailPosition() - def arrived(self, page: TranscriptPageView, key: FragmentKey) -> "ReaderPosition": + def arrived(self, page: TranscriptPageView, key: FragmentKey) -> ReaderPosition: """The position after a message arrived at the followed end: only following hands it the reader.""" return self @@ -349,7 +349,7 @@ class ArrivalPosition(OffsetPosition): def location(self) -> FragmentLocation: return self.page.fragment_row(self.key, 0) - def bodies(self, window: "HistoryWindow") -> tuple[Widget, ...]: + def bodies(self, window: HistoryWindow) -> tuple[Widget, ...]: return (self.page,) def for_layout(self, window: HistoryWindow, position: ReaderPosition) -> ReaderPosition: @@ -360,14 +360,14 @@ def for_layout(self, window: HistoryWindow, position: ReaderPosition) -> ReaderP def laid_out(self, window: HistoryWindow) -> bool: return self.location().laid_out(window) - def destination(self, window: "HistoryWindow") -> float: + def destination(self, window: HistoryWindow) -> float: return self.location().destination(window, 0.0) - def tall(self, window: "HistoryWindow") -> bool: + def tall(self, window: HistoryWindow) -> bool: """Whether the message is taller than the window, in the layout just made.""" return window.message_head_hidden(self.page.drawn_height(self.location().location().fragment), None) - def place(self, window: "HistoryWindow") -> bool: + def place(self, window: HistoryWindow) -> bool: if self.tall(window): return super().place(window) return TailPosition().place(window) diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index 5d2ef8fc9..1e859f63e 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -31,7 +31,8 @@ from toad.core_event_carrier import CoreEventReceiver from toad.frame_presentation import Animated from toad.session_tracker import OpenTab -from toad.widget_view import AUTO, FILL, ToadHosted, WidgetView +from toad.app_node import ToadNode +from toad.widget_view import AUTO, FILL, WidgetView from toad.widgets.selection import ChangingText, ViewLabel @@ -201,13 +202,15 @@ class TabCells(WidgetView, containers.VerticalGroup): tab's span needs no translation and is never past the underline's end. """ - width = AUTO - min_width = Scalar(100, Unit.PERCENT, Unit.WIDTH) + # WidgetView declares its geometry inputs as None, so mypy reads every + # Scalar override as a new type (the other views here hit the same error). + width = AUTO # type: ignore[assignment] + min_width = Scalar(100, Unit.PERCENT, Unit.WIDTH) # type: ignore[assignment] height = Scalar.from_number(2) placement_offset = ScalarOffset.from_offset((0, 1)) -class SessionsTabs(ToadHosted, WidgetView, CoreEventReceiver, Widget): +class SessionsTabs(WidgetView, CoreEventReceiver, Widget, ToadNode): DEFAULT_LAYOUT = VerticalLayout width = FILL height = Scalar.from_number(3) @@ -270,9 +273,7 @@ def selected_span(self) -> tuple[int, int]: selected = self.app.selected_mode if not selected: return 0, 0 - cells = [cell.virtual_region for cell in ( - self.query_one_optional(f"#{selected}", SessionLabel), - self.query_one_optional(f"#close-{selected}", SessionTabClose)) if cell is not None] + cells = [cell.virtual_region for cell in self.title_container.query_children(f"#{selected}, #close-{selected}")] return Region.from_union(cells).column_span if cells else (0, 0) def show_selected_tab(self) -> None: diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index d0441199a..f76639d9e 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -38,7 +38,7 @@ SidebarShift, ) from toad.widgets.sidebar_viewport import SidebarHeader, SidebarViewport -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode if TYPE_CHECKING: from toad.tab_order import TabOrder @@ -268,7 +268,7 @@ class HeaderButton(ActivationKeys, HoverSelection, widgets.Static, can_focus=Tru } """ -class TabHistoryButton(ToadHosted, HeaderButton): +class TabHistoryButton(HeaderButton, ToadNode): """Back or forward through visited tabs.""" direction: ClassVar[int] @@ -344,7 +344,7 @@ async def chosen(self, action: ApplicationAction | None) -> None: await invoke(action.apply, self.app) -class TabHistoryControls(ToadHosted, CoreEventReceiver, containers.HorizontalGroup): +class TabHistoryControls(CoreEventReceiver, containers.HorizontalGroup, ToadNode): """Left-sidebar controls shared by native thread and channel screens.""" DEFAULT_CSS = """ @@ -513,8 +513,7 @@ def activate(self) -> None: self.post_message(self.Pressed(self.action)) class SidebarResizeHandle( - ToadHosted, - MovesHorizontalCursor, CapturedDrag, ColourSelection, widgets.Static, can_focus=True + MovesHorizontalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True ): """Drag the conversation-facing edge; the placement model owns the width.""" @@ -579,8 +578,8 @@ async def apply(self, sidebar: SideBar) -> None: sidebar.search_input.focus() -class SideBar(ToadHosted, DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, - containers.Vertical, actions=(SearchSidebarAction,)): +class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, + containers.Vertical, ToadNode, actions=(SearchSidebarAction,)): tone = SidebarToneColour BINDING_GROUP_TITLE = "Sidebar" DEFAULT_CSS = """ diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index de7b4bb64..1c84ede3e 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -21,7 +21,7 @@ from toad.comms_root import FailedObservation, Observing from toad.navigation_target import NavigationTarget from toad.widgets.channel_activity import ChannelActivity - from toad.widgets.comms_sidebar import CommsRow, CommsSidebar + from toad.widgets.comms_sidebar import CommsRow, CommsSidebar, ListedRow from toad.widgets.thread_comms import RelationshipRows from agent_comms.display_order import ThreadSort @@ -220,7 +220,7 @@ def reconcile_keys(self, keys: Iterable[KeyT], *, changed: frozenset[KeyT]): return ordered -class SidebarGroup[RowT: "CommsRow"](KeyedRows[str], VerticalGroup): +class SidebarGroup[RowT: "ListedRow"](KeyedRows[str], VerticalGroup): """A common header/disclosure and optional bounded member viewport.""" navigation_header = False diff --git a/src/toad/widgets/switch_toast.py b/src/toad/widgets/switch_toast.py index fd67918e4..871e4cfc9 100644 --- a/src/toad/widgets/switch_toast.py +++ b/src/toad/widgets/switch_toast.py @@ -11,15 +11,19 @@ from rich.style import Style from textual.geometry import Offset from textual.strip import Strip -from textual.css.scalar import Scalar from ui_theme.effects import Amount, Effect -from ui_theme.scheme import AccentColour, BackgroundColour, ForegroundColour, HeaderToneColour +from ui_theme.scheme import ( + AccentColour, + BackgroundColour, + ForegroundColour, + HeaderToneColour, +) from toad.core import events as core_events from toad.core_event_carrier import CoreEventReceiver from toad.frame_presentation import Animated from toad.large_text import LargeText -from toad.widget_view import WidgetView +from toad.app_node import ToadNode if TYPE_CHECKING: from textual._paint_state import PaintState @@ -51,7 +55,7 @@ def final(self) -> Amount: TAB_TOAST = Swell(rise=0.12, hold=0.5, fall=0.35, frames_per_second=30) -class SwitchToast(CoreEventReceiver, WidgetView, Animated[Amount]): +class SwitchToast(CoreEventReceiver, Animated[Amount], ToadNode): """The chosen tab's name, so the reader sees where they landed without finding the tab. It never takes focus or pointer input: clicks pass to what is under it. @@ -60,10 +64,9 @@ class SwitchToast(CoreEventReceiver, WidgetView, Animated[Amount]): repaints its own few rows. """ + DEFAULT_CSS = "SwitchToast { position: absolute; overlay: screen; }" can_focus = False hit_test_transparent = True - position = "absolute" - overlay = "screen" # Cells around the name, inside the frame. MARGIN = (1, 6) @@ -78,9 +81,9 @@ def on_mount(self) -> None: @handles(core_events.SessionSelected) def session_selected(self, event: core_events.SessionSelected) -> None: - tabs = {tab.mode_name: tab for tab in self.app.open_tabs} - if (tab := tabs.get(event.mode_name)) is not None: - self.show(tab.title) + titles = {tab.mode_name: tab.title for tab in self.app.open_tabs} + if event.mode_name in titles: + self.show(titles[event.mode_name]) @property def box(self) -> tuple[int, int]: @@ -98,9 +101,11 @@ def show(self, title: str) -> None: fits = LargeText.width(title) + 2 * self.MARGIN[1] + 2 <= self.room self.lines = large if fits else (title,) width, height = self.box - self.width, self.height = Scalar.from_number(width), Scalar.from_number(height) + self.styles.width, self.styles.height = width, height screen = self.screen.size - self.absolute_offset = Offset((screen.width - width) // 2, (screen.height - height) // 2) + self.absolute_offset = Offset( + (screen.width - width) // 2, (screen.height - height) // 2 + ) self.display = True self.run_effect(TAB_TOAST) self.refresh(layout=True) @@ -117,10 +122,18 @@ def repaint(self) -> None: def render_line(self, y: int, paint: PaintState) -> Strip: colours = self.app.colours amount = self.effect_frame(TAB_TOAST).value - background = colours.mixed(BackgroundColour, HeaderToneColour, amount).rich_color - frame = Style(color=colours.mixed(BackgroundColour, AccentColour, amount).rich_color, bgcolor=background) - name = Style(color=colours.mixed(BackgroundColour, ForegroundColour, amount).rich_color, - bgcolor=background, bold=True) + background = colours.mixed( + BackgroundColour, HeaderToneColour, amount + ).rich_color + frame = Style( + color=colours.mixed(BackgroundColour, AccentColour, amount).rich_color, + bgcolor=background, + ) + name = Style( + color=colours.mixed(BackgroundColour, ForegroundColour, amount).rich_color, + bgcolor=background, + bold=True, + ) width, height = self.box inner = max(0, width - 2) if y == 0 or y == height - 1: @@ -131,6 +144,13 @@ def render_line(self, y: int, paint: PaintState) -> Strip: if cell_len(text) > inner: text = set_cell_size(text, max(0, inner - 1)) + "…" padding = inner - cell_len(text) - return Strip([Segment("│", frame), - Segment(" " * (padding // 2) + text + " " * (padding - padding // 2), name), - Segment("│", frame)], width) + return Strip( + [ + Segment("│", frame), + Segment( + " " * (padding // 2) + text + " " * (padding - padding // 2), name + ), + Segment("│", frame), + ], + width, + ) diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index 64319249a..2b27801fd 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -23,7 +23,7 @@ from toad.widgets.line_markdown import LineMarkdown from toad.widgets.message_divider import SingleLineBlock from toad.widgets.tool_content import ToolContent -from toad.widget_view import ToadHosted +from toad.app_node import ToadNode @@ -48,7 +48,7 @@ def toggle_details(self, link: ToggleDetails) -> None: self.app.bell() -class ToolCall(ToadHosted, ConversationBlock, SnapshotPresentation, CategorizedBlock, containers.VerticalGroup): +class ToolCall(ConversationBlock, SnapshotPresentation, CategorizedBlock, containers.VerticalGroup, ToadNode): DEFAULT_CSS = """ ToolCall { margin: 0 !important; From 6cfb02770b4f33efbde9dcbb71c4f8052dc137a1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:07:47 -0400 Subject: [PATCH 11/14] Name the underline's slide mark_selected, not follow The deleted-interface guard (tests/guards/test_deleted_interface_mechanisms.py) seals the retired cursor family's `follow` name; the underline method that slides to the selected cells now says what it does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/widgets/session_tabs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index 1e859f63e..7d062c58a 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -144,7 +144,7 @@ class Underline(WidgetView, Animated[Amount]): _drawn: tuple[float, float] = (0.0, 0.0) _origin: tuple[float, float] = (0.0, 0.0) - def follow(self) -> None: + def mark_selected(self) -> None: """The selected cells may have moved; slide to them from what is drawn.""" start, end = self._drawn if start < end and self._drawn != self.selected_cells: @@ -284,7 +284,7 @@ def show_selected_tab(self) -> None: """ if not self.is_mounted or not self.is_attached: return - self.query_one(Underline).follow() + self.query_one(Underline).mark_selected() selected = self.app.selected_mode if selected and (label := self.query_one_optional(f"#{selected}", SessionLabel)): if label.virtual_region: From 030ff1ada24add510cd34a8391319f10c28a91b2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:20:56 -0400 Subject: [PATCH 12/14] Migrate existing keymap callers to their acquired catalog --- tests/textual/test_keymap_model.py | 21 ++++++++++++--------- tests/textual/test_keymap_screen.py | 7 ++++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/textual/test_keymap_model.py b/tests/textual/test_keymap_model.py index 1b3e52f4b..6615600b6 100644 --- a/tests/textual/test_keymap_model.py +++ b/tests/textual/test_keymap_model.py @@ -3,12 +3,13 @@ from toad.application_actions import CommanderAction, HelpQuitAction, KeymapAction from toad.key_modes import KeyMode, Standard, VimNormal -from toad.keymap import Keymap, KeymapRow +from toad.keymap import Keymap, KeymapCatalog, KeymapRow from toad.preferences import SETTINGS, SavedSettings from toad.screens.store import LaunchSlotAction def test_saved_remaps_keep_modes_independent_and_reset_to_the_declaration(): + catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) ordinary = KeymapRow.of(KeymapAction(), Standard) normal = KeymapRow.of(KeymapAction(), VimNormal) keymap = Keymap().changed(normal, ("f7",)) @@ -17,9 +18,9 @@ def test_saved_remaps_keep_modes_independent_and_reset_to_the_declaration(): settings.set(SETTINGS.ui.keymap.bindings, keymap.bindings) loaded = SavedSettings(settings.document()).current.ui.keymap assert [setting.path for setting in applied] == [SETTINGS.ui.keymap.bindings.bind(settings.state).path] - assert normal.effective(loaded) == ("f7",) - assert ordinary.effective(loaded) == ordinary.defaults - assert normal.effective(loaded.reset(normal)) == normal.defaults + assert catalog.effective(normal, loaded) == ("f7",) + assert catalog.effective(ordinary, loaded) == catalog.defaults(ordinary) + assert catalog.effective(normal, loaded.reset(normal)) == catalog.defaults(normal) with pytest.raises(ValueError, match="Commander.*Keymap|Keymap.*Commander"): loaded.changed(KeymapRow.of(CommanderAction(), VimNormal), ("f7",)) @@ -30,9 +31,10 @@ def test_defaults_allow_the_declared_alternatives_in_every_mode(): def test_reset_cannot_reintroduce_a_collision_and_duplicate_rows_are_refused(): + catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) menu = KeymapRow.of(KeymapAction(), Standard) keymap = Keymap().changed(menu, ("f7",)) - keymap = keymap.changed(KeymapRow.of(CommanderAction(), Standard), menu.defaults) + keymap = keymap.changed(KeymapRow.of(CommanderAction(), Standard), catalog.defaults(menu)) with pytest.raises(ValueError, match="Key conflict"): keymap.reset(menu) with pytest.raises(ValueError, match="only one override"): @@ -40,17 +42,18 @@ def test_reset_cannot_reintroduce_a_collision_and_duplicate_rows_are_refused(): def test_launch_slots_have_distinct_saved_invocations_and_mode_defaults(): + catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) rows = tuple(KeymapRow.of(LaunchSlotAction(slot), Standard) for slot in range(15)) assert len({row.declaration for row in rows}) == len(rows) - assert tuple(row.defaults for row in rows) == tuple((key,) for key in LaunchSlotAction.keys[Standard().binding_mode]) + assert tuple(catalog.defaults(row) for row in rows) == tuple((key,) for key in LaunchSlotAction.keys[Standard().binding_mode]) keymap = Keymap().changed(rows[0], ("f6",)) settings = SavedSettings() settings.set(SETTINGS.ui.keymap.bindings, keymap.bindings) loaded = SavedSettings(settings.document()).current.ui.keymap assert loaded == keymap - assert rows[0].effective(loaded) == ("f6",) - assert rows[1].effective(loaded) == rows[1].defaults - assert KeymapRow.of(LaunchSlotAction(0), VimNormal).effective(loaded) == ("1",) + assert catalog.effective(rows[0], loaded) == ("f6",) + assert catalog.effective(rows[1], loaded) == catalog.defaults(rows[1]) + assert catalog.effective(KeymapRow.of(LaunchSlotAction(0), VimNormal), loaded) == ("1",) def test_hidden_system_bindings_keep_their_valid_saved_references(): diff --git a/tests/textual/test_keymap_screen.py b/tests/textual/test_keymap_screen.py index addfc4823..f96983409 100644 --- a/tests/textual/test_keymap_screen.py +++ b/tests/textual/test_keymap_screen.py @@ -115,17 +115,18 @@ async def test_the_menu_discovers_and_remaps_a_native_callable_with_both_aliases counter.focus() invocation = counter.MODE_BINDINGS[0].invocation row = KeymapRow.of(invocation, Standard) - assert row.defaults == ("f8", "f9") await ui.press("f1") await ui.until(lambda: isinstance(ui.app.screen, KeymapScreen), why="the native callable in the menu") + catalog = ui.app.screen.catalog + assert catalog.defaults(row) == ("f8", "f9") assert len(ui.app.screen.modes) == 4 await choose(ui, invocation) ui.app.screen.query_one("#keymap-keys", Input).value = "f6" await ui.pilot.click("#keymap-apply") - await ui.until(lambda: row.effective(ui.app.settings.ui.keymap) == ("f6",), + await ui.until(lambda: catalog.effective(row, ui.app.settings.ui.keymap) == ("f6",), why="the saved callable remap") loaded = SavedSettings(ui.app.saved_settings.document()).current.ui.keymap - assert row.effective(loaded) == ("f6",) + assert catalog.effective(row, loaded) == ("f6",) await ui.press("escape") counter.focus() await ui.press("f8", "f9") From 87ac98cd6f09b9e5691d55cf720b8971504792a5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:34:03 -0400 Subject: [PATCH 13/14] Share sidebar contextual activation between pointer and native hints --- src/toad/widgets/comms_sidebar.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index bbb885ca8..e0579af98 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -27,7 +27,7 @@ from textual.reactive import reactive from ui_theme.effects import NEW_MESSAGE from textual.widgets import Static -from textual._activation import PrimaryActivation +from textual._activation import ContextActivation, PrimaryActivation from ui_theme.scheme import AlternateRowToneColour @@ -150,7 +150,7 @@ def repaint_members(self, names: frozenset[str]) -> None: class CommsRow( - TargetTreeMember, OpensSelected, PrimaryActivation, CoreEventReceiver, HoverSelection, ViewLabel, + TargetTreeMember, OpensSelected, PrimaryActivation, ContextActivation, CoreEventReceiver, HoverSelection, ViewLabel, scopes={HiddenActions: (CursorAction, OpenSelectedAction)}, ): """One interactive row: a channel or a thread.""" @@ -172,6 +172,8 @@ def target_name(self) -> str: def show_menu(self, sidebar: TargetTree, offset: Offset) -> None: sidebar.show_row_menu(self, offset) + def context_activation(self, offset: Offset) -> None: + self.show_menu(self.navigation_tree, offset) can_focus = True @@ -220,7 +222,7 @@ def open_selected(self) -> None: def on_click(self, event) -> None: if event.button == 3: event.stop() - self.show_menu(self.navigation_tree, event.screen_offset) + self.context_activation(event.screen_offset) return if event.ctrl or event.shift: event.stop() From d12963a9d35bd4a55c1d7d18148c05fa795ec9be Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 15:03:40 -0400 Subject: [PATCH 14/14] Compare settled layouts and transcript content in UI controls --- tests/textual/test_channel_scrolling.py | 7 +++++-- tests/textual/test_stepped_layout_restoration.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/textual/test_channel_scrolling.py b/tests/textual/test_channel_scrolling.py index 802e41ea5..177d31ada 100644 --- a/tests/textual/test_channel_scrolling.py +++ b/tests/textual/test_channel_scrolling.py @@ -116,8 +116,11 @@ async def test_a_growing_channel_draws_new_messages_for_a_tail_reader_and_keeps_ await ui.until(lambda: history.state.accepts_source_work and not window.follows_tail, why="the reader above the tail") await ui.step() def message_rows(): - # The message area's part of each screen line; the sidebar's unread count changes beside it. - return [line.rsplit("│", 1)[-1] for line in ui.visible_lines() if "Message" in line] + # Read the message area, excluding sidebar and scrollbar ink. + region = window.scrollable_content_region + rows = (strip.crop(region.x, region.right).text + for strip in ui.app.screen._compositor.render_strips()[region.y:region.bottom]) + return [line for line in rows if "Message" in line] position, top = window.scroll_y, message_rows() for index in range(60): diff --git a/tests/textual/test_stepped_layout_restoration.py b/tests/textual/test_stepped_layout_restoration.py index 5b3777396..2ec2ff06f 100644 --- a/tests/textual/test_stepped_layout_restoration.py +++ b/tests/textual/test_stepped_layout_restoration.py @@ -48,7 +48,8 @@ async def test_stepped_layout_keeps_previous_scene_and_restoration_waits_for_the async def settled(): await ui.step(0.1) seen.append(scene(screen)) - return len(seen) > 2 and seen[-1] == seen[-2] == seen[-3] + return (not ui.app.workspace_chrome.switch_toast.display + and len(seen) > 2 and seen[-1] == seen[-2] == seen[-3]) await ui.until(settled, why="the workspace's widgets to finish mounting")