From a3f6a0926d4bb58af8b8bb494e1e617c83eb6b41 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:19:04 -0400 Subject: [PATCH 01/18] Move grid and workspace keys onto existing declaration owners --- src/toad/screens/welcome.py | 15 ++++++++------- src/toad/screens/workspace.py | 23 +++++++++++------------ src/toad/widgets/grid_select.py | 20 ++++---------------- 3 files changed, 23 insertions(+), 35 deletions(-) diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 621beb2af..b344ed6e3 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -13,8 +13,9 @@ from agent_comms.repo import Checkout from agent_comms.ui_model.sidebar import SidebarModel from textual.app import ComposeResult -from textual.binding import Binding -from toad.widget_actions import TogglesIrc +from textual.binding import ModeBinding +from textual.document._editing import EditingMode +from toad.widget_actions import NativeMethodKeys, TogglesIrc from textual.containers import VerticalScroll from textual.geometry import Offset from textual.widgets import Static @@ -190,13 +191,9 @@ async def present_selected(self, event: SessionSelected) -> None: self.present() -class WelcomeScreen(TogglesIrc, ProjectSessionOrigin, SessionView, ToadNode, can_focus=False): +class WelcomeScreen(NativeMethodKeys, TogglesIrc, ProjectSessionOrigin, SessionView, ToadNode, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" - BINDINGS = [ - Binding("n", "new_session", "New session"), - ] - DEFAULT_CSS = """ WelcomeScreen { align: center middle; } WelcomeScreen > VerticalScroll { width: 64; max-width: 100%; height: auto; max-height: 100%; } @@ -265,5 +262,9 @@ async def retire_presentation(self) -> None: async def action_new_session(self) -> None: await NewThreadAction().apply(self.app) + MODE_BINDINGS = ( + ModeBinding("n", action_new_session, "New session", mode=EditingMode), + ) + async def toggle_irc(self) -> None: await self.open_sidebar_target(FeedTarget()) diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index 7e9eec6e1..062dac459 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -7,7 +7,7 @@ from functools import cached_property from agent_comms.declared_family import DeclaredFamily -from textual.binding import Binding, BindingsMap +from textual.binding import BindingsMap, ModeBinding from textual.document._editing import EditingMode from textual.box_model import DEFAULT_LAYER from textual.events import ScreenResume @@ -318,18 +318,17 @@ def _on_screen_resume(self, event: ScreenResume) -> None: class WorkspaceScreen(WorkspaceFrame, actions=(ViewAction,)): """The shared live workspace, with its own navigation controls.""" - BINDINGS = [ - Binding("escape", "back_to_bottom", "Back to bottom", priority=True, show=False), - ] - - def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: - if action == "back_to_bottom": - return ( - not (self.focused is not None and self.focused.uses_screen_coordinates) - and self.back_to_bottom_window is not None - ) - return super().check_action(action, parameters) + def back_to_bottom_available(self) -> bool: + return ( + not (self.focused is not None and self.focused.uses_screen_coordinates) + and self.back_to_bottom_window is not None + ) def action_back_to_bottom(self) -> None: if (window := self.back_to_bottom_window) is not None: window.jump_to_latest() + + MODE_BINDINGS = ( + ModeBinding("escape", action_back_to_bottom, "Back to bottom", mode=EditingMode, + available=back_to_bottom_available, priority=True, show=False), + ) diff --git a/src/toad/widgets/grid_select.py b/src/toad/widgets/grid_select.py index 4d88c2c72..ea5ea2424 100644 --- a/src/toad/widgets/grid_select.py +++ b/src/toad/widgets/grid_select.py @@ -1,22 +1,16 @@ from dataclasses import dataclass from textual import containers -from textual.binding import Binding from textual import events from textual.message import Message from textual.reactive import reactive from textual.layouts.grid import GridLayout from textual.widget import Widget -from toad.widget_actions import MovesCursor, Selects +from toad.widget_actions import MovesCursor, MovesHorizontalCursor, Selects -class GridSelect(MovesCursor, Selects, containers.ItemGrid, can_focus=True): +class GridSelect(MovesCursor, MovesHorizontalCursor, Selects, containers.ItemGrid, can_focus=True): FOCUS_ON_CLICK = False - CURSOR_GROUP = Binding.Group("Select") - BINDINGS = [ - Binding("left", "cursor_left", "Cursor Left", group=CURSOR_GROUP), - Binding("right", "cursor_right", "Cursor Right", group=CURSOR_GROUP), - ] highlighted: reactive[int | None] = reactive(None) @@ -155,17 +149,11 @@ def cursor_down(self): else: self.post_message(self.LeaveDown(self)) - def action_cursor_left(self): + def cursor_horizontal(self, direction): if self.highlighted is None: self.highlighted = 0 else: - self.highlighted -= 1 - - def action_cursor_right(self): - if self.highlighted is None: - self.highlighted = 0 - else: - self.highlighted += 1 + self.highlighted += direction def on_click(self, event: events.Click) -> None: if event.widget is None: From 94405654021700e42dd9d2e1e8d3092a1a6a6e44 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:49:12 -0400 Subject: [PATCH 02/18] Derive permission and sidebar navigation from the shared key family --- src/toad/question_actions.py | 37 --------------------- src/toad/screens/permissions.py | 4 +-- src/toad/widget_actions.py | 38 +++++----------------- src/toad/widgets/comms_sidebar.py | 5 +-- src/toad/widgets/grid_select.py | 9 ++--- src/toad/widgets/model_switcher.py | 7 ++-- src/toad/widgets/path_search.py | 9 ++--- src/toad/widgets/project_directory_tree.py | 14 ++------ src/toad/widgets/question.py | 17 ++++++++-- src/toad/widgets/sidebar_tree.py | 20 ++++-------- src/toad/widgets/slash_complete.py | 9 ++--- 11 files changed, 56 insertions(+), 113 deletions(-) diff --git a/src/toad/question_actions.py b/src/toad/question_actions.py index 495303378..191f5a4c6 100644 --- a/src/toad/question_actions.py +++ b/src/toad/question_actions.py @@ -34,24 +34,6 @@ def available(self, context): return context.question.accepts_selection -class SelectionUpAction(SelectionAction): - keys = {EditingMode: ("up",)} - - async def apply(self, context): - question = context.question - question._reset_blink() - question.selection = max(0, question.selection - 1) - - -class SelectionDownAction(SelectionAction): - keys = {EditingMode: ("down",)} - - async def apply(self, context): - question = context.question - question._reset_blink() - question.selection = min(len(question.options) - 1, question.selection + 1) - - class SelectAction(QuestionAction): """Answer with the selected option.""" @@ -114,22 +96,3 @@ class RejectAlwaysAction(SelectKindAction): title = "Reject always" keys = {EditingMode: ("R",)} group = RejectOnceAction.group - - -class PermissionNavigationAction(Action, declared_name=None): - group = Binding.Group("Navigation", compact=True) - show = True - - -class NextChangeAction(PermissionNavigationAction): - keys = {EditingMode: ("j",)} - - async def apply(self, context): - context.navigator.action_cursor_down() - - -class PreviousChangeAction(PermissionNavigationAction): - keys = {EditingMode: ("k",)} - - async def apply(self, context): - context.navigator.action_cursor_up() diff --git a/src/toad/screens/permissions.py b/src/toad/screens/permissions.py index 57d8603a9..9031f9a15 100644 --- a/src/toad/screens/permissions.py +++ b/src/toad/screens/permissions.py @@ -17,7 +17,7 @@ from toad.answer import Answer from toad.widgets.question import Question -from toad.question_actions import SelectKindAction, QuestionContext, PermissionNavigationAction +from toad.question_actions import SelectKindAction, QuestionContext from toad.app import ToadApp @@ -38,7 +38,7 @@ class ToolScroll(containers.VerticalScroll): class PermissionsScreen(QuestionContext, PriorityActions, CyclesFocus, Screen[Answer], - actions=(SelectKindAction, PermissionNavigationAction), + actions=(SelectKindAction,), scopes={PriorityActions: (SelectKindAction,)}): BINDING_GROUP_TITLE = "Permissions" AUTO_FOCUS = "Question" diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index aaefaa9f5..2912a1731 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -298,25 +298,8 @@ async def apply(self, context: DismissibleControl) -> None: context.dismiss_control() -class CursorAction(VisibleAction, affix="Action"): +class HorizontalCursorAction(VisibleAction, declared_name=None): group = Binding.Group("Select") - - -class CursorUpAction(CursorAction): - keys = {EditingMode: ("up",)} - - async def apply(self, context: MovesCursor) -> None: - context.cursor_up() - - -class CursorDownAction(CursorAction): - keys = {EditingMode: ("down",)} - - async def apply(self, context: MovesCursor) -> None: - context.cursor_down() - - -class HorizontalCursorAction(CursorAction, declared_name=None): direction: ClassVar[Literal[-1, 1]] async def apply(self, context: MovesHorizontalCursor) -> None: @@ -426,15 +409,6 @@ def dismiss_control(self) -> None: """Close this control without imposing a native screen result contract.""" -class MovesCursor(DeclaredWidgetActions, actions=(CursorAction,)): - - @abstractmethod - def cursor_up(self) -> None: ... - - @abstractmethod - def cursor_down(self) -> None: ... - - class MovesHorizontalCursor(DeclaredWidgetActions, actions=(HorizontalCursorAction,)): @abstractmethod def cursor_horizontal(self, direction: Literal[-1, 1]) -> None: ... @@ -503,7 +477,7 @@ class CyclesFocus(DeclaredWidgetActions, actions=(FocusAction,)): Combiner = Callable[[bool, bool], bool] -PresentationScopes = Mapping[type, tuple[type[Action], ...]] +PresentationScopes = Mapping[type, tuple[type[KeyDeclaration], ...]] @dataclass(frozen=True) @@ -530,17 +504,21 @@ def __init_subclass__( super().__init_subclass__(actions=actions, **kwargs) @classmethod - def present_binding(cls, declaration: type[Action], binding: Binding) -> Binding: + def present_binding(cls, declaration: type[KeyDeclaration], binding: Binding) -> Binding: scopes = {family: members for base in reversed(cls.__mro__) for family, members in base.__dict__.get("scopes", {}).items()} for base in reversed(cls.__mro__): for style in base.__dict__.get("binding_styles", ()): - selected = any(root in declaration.__mro__ for root in scopes.get(base, (Action,))) + selected = any(root in declaration.__mro__ for root in scopes.get(base, (KeyDeclaration,))) binding = replace(binding, **{ style.field.name: style.combine(bool(style.field.current(binding)), selected) }) return binding + @classmethod + def present_navigation_binding(cls, declaration, binding): + return cls.present_binding(declaration, super().present_navigation_binding(declaration, binding)) + @classmethod def action_bindings(cls, declaration: type[Action], mode: type[EditingMode]) -> tuple[Binding, ...]: return tuple(cls.present_binding(declaration, binding) diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index bdcda4ff5..b69ca53bc 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -40,7 +40,8 @@ from toad.session_tracker import SidebarSelection from toad.widgets.selection import ChangingText, HoverSelection, ViewLabel from toad.application_actions import NewThreadAction -from toad.widget_actions import CursorAction, HiddenActions, OpenSelectedAction, OpensSelected +from textual.navigation import CursorCommand +from toad.widget_actions import 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 @@ -165,7 +166,7 @@ def repaint_members(self, names: frozenset[str]) -> None: class CommsRow( TargetTreeMember, OpensSelected, PrimaryActivation, ContextActivation, CoreEventReceiver, HoverSelection, ViewLabel, - scopes={HiddenActions: (CursorAction, OpenSelectedAction)}, + scopes={HiddenActions: (CursorCommand, OpenSelectedAction)}, ): """One interactive row: a channel or a thread.""" diff --git a/src/toad/widgets/grid_select.py b/src/toad/widgets/grid_select.py index ea5ea2424..6845f5850 100644 --- a/src/toad/widgets/grid_select.py +++ b/src/toad/widgets/grid_select.py @@ -6,10 +6,11 @@ from textual.reactive import reactive from textual.layouts.grid import GridLayout from textual.widget import Widget -from toad.widget_actions import MovesCursor, MovesHorizontalCursor, Selects +from textual.navigation import MovesCursor +from toad.widget_actions import MovesHorizontalCursor, Selects, VisibleActions -class GridSelect(MovesCursor, MovesHorizontalCursor, Selects, containers.ItemGrid, can_focus=True): +class GridSelect(VisibleActions, MovesCursor, MovesHorizontalCursor, Selects, containers.ItemGrid, can_focus=True): FOCUS_ON_CLICK = False highlighted: reactive[int | None] = reactive(None) @@ -122,7 +123,7 @@ def validate_highlighted(self, highlighted: int | None) -> int | None: return len(self.children) - 1 return highlighted - def cursor_up(self): + def action_cursor_up(self): if (grid_size := self.grid_size) is None: self.post_message(self.LeaveUp(self)) return @@ -135,7 +136,7 @@ def cursor_up(self): else: self.post_message(self.LeaveUp(self)) - def cursor_down(self): + def action_cursor_down(self): if (grid_size := self.grid_size) is None: self.post_message(self.LeaveDown(self)) return diff --git a/src/toad/widgets/model_switcher.py b/src/toad/widgets/model_switcher.py index b3ecffc7c..163245f47 100644 --- a/src/toad/widgets/model_switcher.py +++ b/src/toad/widgets/model_switcher.py @@ -11,7 +11,8 @@ from textual import getters, on, work from textual.app import ComposeResult from textual.document._editing import EditingMode -from toad.widget_actions import WidgetAction, PriorityActions, MovesCursor, Submits, DismissibleControl +from textual.navigation import MovesCursor +from toad.widget_actions import WidgetAction, PriorityActions, Submits, DismissibleControl from textual.content import Content from textual.containers import VerticalGroup from textual.reactive import var @@ -258,11 +259,11 @@ def on_option_selected(self, event: OptionList.OptionSelected): if event.option_id is not None: self.select_model(event.option_id) - def cursor_up(self): + def action_cursor_up(self): self._selection_moved = True self.option_list.action_cursor_up() - def cursor_down(self): + def action_cursor_down(self): self._selection_moved = True self.option_list.action_cursor_down() diff --git a/src/toad/widgets/path_search.py b/src/toad/widgets/path_search.py index 2dab5bbf8..b9811250a 100644 --- a/src/toad/widgets/path_search.py +++ b/src/toad/widgets/path_search.py @@ -13,7 +13,8 @@ from textual import on from textual.app import ComposeResult from textual.document._editing import EditingMode -from toad.widget_actions import WidgetAction, PriorityActions, HiddenActions, MovesCursor, Submits, DismissAction, SubmitAction +from textual.navigation import MovesCursor +from toad.widget_actions import WidgetAction, PriorityActions, HiddenActions, Submits, DismissAction, SubmitAction, VisibleActions from textual import work from textual import getters from textual import containers @@ -146,7 +147,7 @@ async def apply(self, context: PathSearch) -> None: class PathSearch( - CoreEventReceiver, PriorityActions, HiddenActions, MovesCursor, Submits, CompletionPopup, + CoreEventReceiver, PriorityActions, HiddenActions, VisibleActions, MovesCursor, Submits, CompletionPopup, scopes={HiddenActions: (DismissAction, SubmitAction)}, actions=(SwitchPathPickerAction,), ): @@ -275,14 +276,14 @@ def highlight_offsets(path: Content, offsets: Sequence[int]) -> Content: self.option_list.highlighted = 0 self.post_message(PromptSuggestion("")) - def cursor_down(self) -> None: + def action_cursor_down(self) -> None: if self.show_tree_picker: if tree := self.query_one_optional(ProjectDirectoryTree): tree.action_cursor_down() else: self.option_list.action_cursor_down() - def cursor_up(self) -> None: + def action_cursor_up(self) -> None: if self.show_tree_picker: if tree := self.query_one_optional(ProjectDirectoryTree): tree.action_cursor_up() diff --git a/src/toad/widgets/project_directory_tree.py b/src/toad/widgets/project_directory_tree.py index b3337bb9c..0d0ca0b01 100644 --- a/src/toad/widgets/project_directory_tree.py +++ b/src/toad/widgets/project_directory_tree.py @@ -4,9 +4,9 @@ import asyncio from textual import work -from textual.binding import Binding, ModeBinding +from textual.binding import ModeBinding from textual.document._editing import EditingMode -from toad.widget_actions import DismissAction, NativeMethodKeys +from toad.widget_actions import NativeMethodKeys from textual.message import Message from textual.widgets import DirectoryTree from textual.widgets.directory_tree import DirEntry @@ -33,16 +33,6 @@ def __init__(self, path: Path) -> None: self.path = path super().__init__() - BINDINGS = [ - Binding( - "ctrl+c", - DismissAction.binding_name(), - "Interrupt", - tooltip="Interrupt running command", - show=False, - ), - ] - def __init__( self, path: str | Path, diff --git a/src/toad/widgets/question.py b/src/toad/widgets/question.py index a6d675ae7..8e0fc2506 100644 --- a/src/toad/widgets/question.py +++ b/src/toad/widgets/question.py @@ -8,6 +8,7 @@ from textual.app import ComposeResult from textual.content import Content from textual.message import Message +from textual.navigation import MovesCursor from textual.reactive import reactive, var from textual.widget import Widget from textual.screen import Screen @@ -16,7 +17,7 @@ from toad.tone_paint import UntintedTonePaint from toad.question_actions import QuestionAction, QuestionContext, SelectKindAction from toad.question_presentation import QuestionPresentation -from toad.widget_actions import DeclaredWidgetActions +from toad.widget_actions import VisibleActions type Options = list[Answer] @@ -122,7 +123,7 @@ def select_option(self) -> None: self.post_message(self.Selected(self.index)) -class Question(QuestionContext, DeclaredWidgetActions, containers.VerticalGroup, actions=(QuestionAction,), can_focus=True): +class Question(VisibleActions, MovesCursor, QuestionContext, containers.VerticalGroup, actions=(QuestionAction,), can_focus=True): """A text question with a menu of responses.""" BINDING_GROUP_TITLE = "Question" @@ -256,6 +257,18 @@ def question(self) -> Question: def accepts_selection(self) -> bool: return not self.selected and 0 <= self.selection < len(self.options) + @property + def accepts_navigation(self) -> bool: + return self.accepts_selection + + def action_cursor_up(self) -> None: + self._reset_blink() + self.selection = max(0, self.selection - 1) + + def action_cursor_down(self) -> None: + self._reset_blink() + self.selection = min(len(self.options) - 1, self.selection + 1) + def watch_selection(self, old_selection: int, new_selection: int) -> None: self.presentation.mount.select(new_selection) diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 7342ad0ba..735485d25 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -32,7 +32,8 @@ from toad.widget_actions import Activates from textual._measurement import FLOW_HEIGHT, height_dependency from textual.app import ComposeResult -from toad.widget_actions import CursorAction, HiddenActions, MovesCursor +from textual.navigation import CursorCommand, MovesCursor +from toad.widget_actions import HiddenActions, VisibleActions from textual.containers import Vertical, VerticalGroup from textual.geometry import Offset, Size from textual.layouts.vertical import VerticalLayout @@ -64,20 +65,13 @@ def __init__(self, name: str) -> None: ) -class TargetTreeMember(HiddenActions, MovesCursor): +class TargetTreeMember(HiddenActions): """A navigation control belongs to one required native tree.""" @property def navigation_tree(self) -> TargetTree: return self.query_ancestor(TargetTree) - def cursor_up(self) -> None: - self.navigation_tree.cursor_up() - - def cursor_down(self) -> None: - self.navigation_tree.cursor_down() - - class SidebarDisclosure(Activates, Static, can_focus=True): DEFAULT_CSS = "SidebarDisclosure { width: 2; height: 1; pointer: pointer; }" @@ -732,8 +726,8 @@ def _apply_search(self, value: str) -> None: group.sync_members(frozenset(group.member_keys)) -class TargetTree(HiddenActions, MovesCursor, Vertical, - scopes={HiddenActions: (CursorAction,)}): +class TargetTree(HiddenActions, VisibleActions, MovesCursor, Vertical, + scopes={HiddenActions: (CursorCommand,)}): """Common row keyboard mechanics; specialized trees own data and state.""" navigation_groups: Iterable[SidebarGroup] @@ -907,10 +901,10 @@ def rows(self) -> list[CommsRow]: row for group in self.navigation_groups for row in group.navigation_rows ] - def cursor_up(self) -> None: + def action_cursor_up(self) -> None: self.move_cursor(-1) - def cursor_down(self) -> None: + def action_cursor_down(self) -> None: self.move_cursor(1) def move_cursor(self, direction: int) -> None: diff --git a/src/toad/widgets/slash_complete.py b/src/toad/widgets/slash_complete.py index ac7507a5c..94c953d38 100644 --- a/src/toad/widgets/slash_complete.py +++ b/src/toad/widgets/slash_complete.py @@ -3,7 +3,8 @@ from textual import on from textual.app import ComposeResult -from toad.widget_actions import PriorityActions, MovesCursor, Submits +from textual.navigation import MovesCursor +from toad.widget_actions import PriorityActions, Submits, VisibleActions from textual import getters from textual.message import Message @@ -35,7 +36,7 @@ class SlashCompleteInput(widgets.Input): """ -class SlashComplete(PriorityActions, MovesCursor, Submits, CompletionPopup): +class SlashComplete(PriorityActions, VisibleActions, MovesCursor, Submits, CompletionPopup): """A widget to auto-complete slash commands.""" requires_completion = True @@ -128,10 +129,10 @@ def filter_slash_commands(self, prompt: str) -> None: with self.option_list.prevent(widgets.OptionList.OptionHighlighted): self.option_list.highlighted = 0 - def cursor_down(self) -> None: + def action_cursor_down(self) -> None: self.option_list.action_cursor_down() - def cursor_up(self) -> None: + def action_cursor_up(self) -> None: self.option_list.action_cursor_up() def submit(self) -> None: From a02a7d6e1bab38851eed88a65f8cf991bcf9b1e1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:41:19 -0400 Subject: [PATCH 03/18] Type the shared keymap declaration contexts --- src/toad/widget_actions.py | 19 +++++++++++-------- src/toad/widgets/grid_select.py | 7 ++++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index 2912a1731..a78c40c20 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -7,7 +7,7 @@ from functools import partialmethod from operator import or_ from types import MappingProxyType -from typing import ClassVar, Generic, Literal, Self, TypeVar +from typing import TYPE_CHECKING, ClassVar, Generic, Literal, Self, TypeVar from agent_comms.command import Command from agent_comms.declared_family import DeclaredFamily @@ -23,6 +23,9 @@ from copy import replace from dataclasses import dataclass +if TYPE_CHECKING: + from toad.app import ToadApp + Context = TypeVar("Context", bound=DOMNode) @@ -154,7 +157,7 @@ class System: system: ClassVar[bool] = True @classmethod - def offered(cls, context) -> Iterator[Action]: + def offered(cls, context: DOMNode) -> Iterator[Self]: return iter(()) @@ -189,7 +192,7 @@ def exclusive_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: return cls.bindings_for(mode) @classmethod - def menu(cls, app): + def menu(cls, app: ToadApp) -> tuple[tuple[ApplicationAction, str], ...]: """Every offered member under its label, in declaration order.""" return tuple((command, command.label(app)) for member in cls.members_with(cls) for command in member.offered(app)) @@ -198,7 +201,7 @@ def menu(cls, app): class VimWorkspaceAction(ApplicationAction, declared_name=None): """Workspace commands precede editor bindings only in idle modal command entry.""" - def key_available(self, app): + def key_available(self, app: ToadApp) -> bool: if app.current_mode != "workspace": return False return (app.screen is app.workspace_screen @@ -229,7 +232,7 @@ def declared_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: *(binding for declaration in cls.declared_actions() for binding in cls.action_bindings(declaration, mode))) - def __init_subclass__(cls, actions: tuple[type[Action], ...] = (), **kwargs): + def __init_subclass__(cls, actions: tuple[type[Action], ...] = (), **kwargs: object) -> None: cls.own_actions = actions declarations = cls.declared_actions() for declaration in declarations: @@ -244,13 +247,13 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No return declaration.parse(parameters).key_available(self) return super().check_action(action, parameters) - def _run_declared_action(self, declaration, *parameters) -> object: + def _run_declared_action(self, declaration: type[Action], *parameters: object) -> object: command = declaration.parse(parameters) if command.available(self): return command.apply(self) return None - async def _run_declared_async_action(self, declaration, *parameters) -> None: + async def _run_declared_async_action(self, declaration: type[Action], *parameters: object) -> None: await invoke(self._run_declared_action, declaration, *parameters) @@ -516,7 +519,7 @@ def present_binding(cls, declaration: type[KeyDeclaration], binding: Binding) -> return binding @classmethod - def present_navigation_binding(cls, declaration, binding): + def present_navigation_binding(cls, declaration: type[KeyDeclaration], binding: Binding) -> Binding: return cls.present_binding(declaration, super().present_navigation_binding(declaration, binding)) @classmethod diff --git a/src/toad/widgets/grid_select.py b/src/toad/widgets/grid_select.py index 6845f5850..aba043a6a 100644 --- a/src/toad/widgets/grid_select.py +++ b/src/toad/widgets/grid_select.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Literal from textual import containers from textual import events @@ -123,7 +124,7 @@ def validate_highlighted(self, highlighted: int | None) -> int | None: return len(self.children) - 1 return highlighted - def action_cursor_up(self): + def action_cursor_up(self) -> None: if (grid_size := self.grid_size) is None: self.post_message(self.LeaveUp(self)) return @@ -136,7 +137,7 @@ def action_cursor_up(self): else: self.post_message(self.LeaveUp(self)) - def action_cursor_down(self): + def action_cursor_down(self) -> None: if (grid_size := self.grid_size) is None: self.post_message(self.LeaveDown(self)) return @@ -150,7 +151,7 @@ def action_cursor_down(self): else: self.post_message(self.LeaveDown(self)) - def cursor_horizontal(self, direction): + def cursor_horizontal(self, direction: Literal[-1, 1]) -> None: if self.highlighted is None: self.highlighted = 0 else: From 4e568c8c8866cc8ceb9cb63f4019cc38c17506f9 Mon Sep 17 00:00:00 2001 From: toad-keymap Date: Fri, 30 Oct 2026 02:19:46 -0400 Subject: [PATCH 04/18] fix: publish captured block menus with their paired Textual source --- src/toad/block_content.py | 4 +- src/toad/keymap.py | 52 +++++-- src/toad/menus.py | 190 ++++++++++++++++++++++- src/toad/message_rows.py | 8 +- src/toad/widget_actions.py | 13 -- src/toad/widgets/agent_thought.py | 6 +- src/toad/widgets/conversation.py | 17 +- src/toad/widgets/linked_rows.py | 4 +- src/toad/widgets/menu.py | 72 +++++---- src/toad/widgets/shell_terminal.py | 8 +- src/toad/widgets/tool_call.py | 8 +- src/toad/widgets/transcript_fragments.py | 4 +- uv.lock | 2 +- 13 files changed, 296 insertions(+), 92 deletions(-) diff --git a/src/toad/block_content.py b/src/toad/block_content.py index c168baef0..c5c5f808f 100644 --- a/src/toad/block_content.py +++ b/src/toad/block_content.py @@ -4,7 +4,7 @@ from textual.widgets import _markdown as native_markdown -from toad.menus import MenuItem +from toad.menus import MenuCommand class BlockContent: @@ -17,7 +17,7 @@ def block_cursor(self): return AtomicBlockCursor(self) - def get_block_menu(self) -> Iterable[MenuItem]: + def get_block_menu(self) -> Iterable[MenuCommand]: return () def get_clipboard_text(self) -> str | None: diff --git a/src/toad/keymap.py b/src/toad/keymap.py index 81bae44fb..e7ec4b4fa 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, replace -from collections.abc import Mapping +from collections.abc import Iterable, Iterator, Mapping from functools import cache from importlib import import_module from pkgutil import walk_packages @@ -21,6 +21,7 @@ from textual.screen import ModalScreen from toad.key_modes import KeyMode +from toad.menus import MenuCommand from toad.setting_declarations import Effect, FamilyChoices from toad.widget_actions import ApplicationAction @@ -93,42 +94,66 @@ def __str__(self) -> str: f"({FamilyChoices(KeyMode).label(self.first.mode)})") +@dataclass(frozen=True) +class KeymapClaim: + """A detached default claim, with its original nominal invocation intact.""" + + invocation: KeyInvocation + keys: tuple[str, ...] + + @classmethod + def from_bindings(cls, bindings: Iterable[Binding]) -> Iterator[KeymapClaim]: + return (cls(invocation, (key,)) for key, binding in BindingsMap(bindings) + for invocation in binding.invocations()) + + @dataclass(frozen=True) class KeymapCatalog: """A detached view of the same concrete bindings the native scopes dispatch.""" invocations: Mapping[KeyInvocationId, KeyInvocation] - scopes: Mapping[type[KeyMode], tuple[tuple[Binding, ...], ...]] + scopes: Mapping[type[KeyMode], tuple[tuple[KeymapClaim, ...], ...]] default_keys: Mapping[tuple[KeyInvocationId, type[KeyMode]], tuple[str, ...]] @classmethod def capture(cls, modes: tuple[type[KeyMode], ...]) -> KeymapCatalog: discover() invocations: dict[KeyInvocationId, KeyInvocation] = {} - scopes: dict[type[KeyMode], tuple[tuple[Binding, ...], ...]] = {} + scopes: dict[type[KeyMode], tuple[tuple[KeymapClaim, ...], ...]] = {} keys: dict[tuple[KeyInvocationId, type[KeyMode]], dict[str, None]] = {} for mode in modes: native_mode = mode().binding_mode application = tuple(binding for declaration in ApplicationAction.declarations() for binding in declaration.exclusive_bindings(native_mode)) - owned = [application] + owned = [tuple(KeymapClaim.from_bindings(application))] for owner in DOMNode.contexts(): defaults = tuple(binding for _, binding in owner.binding_defaults(native_mode)) if not issubclass(owner, ModalScreen): defaults = (*defaults, *application) - owned.append(defaults) + owned.append(tuple(KeymapClaim.from_bindings(defaults))) + from toad.widgets.menu import Menu + owned.append(( + *KeymapClaim.from_bindings(binding for _, binding in Menu.binding_defaults(native_mode)), + *(KeymapClaim(declaration, declaration.default_keys()) + for declaration in MenuCommand.members_with(MenuCommand)), + )) scopes[mode] = tuple(owned) for defaults in owned: - for key, binding in BindingsMap(defaults): - for invocation in binding.invocations(): - reference = KeyInvocationId(invocation.key_identity()) - invocations.setdefault(reference, invocation) + for claim in defaults: + reference = KeyInvocationId(claim.invocation.key_identity()) + invocations.setdefault(reference, claim.invocation) + for key in claim.keys: keys.setdefault((reference, mode), {})[key] = None return cls(MappingProxyType(invocations), MappingProxyType(scopes), MappingProxyType({ (reference, mode): tuple(keys.get((reference, mode), ())) for reference in invocations for mode in modes })) + @staticmethod + def menu_keys(command: MenuCommand, mode: type[KeyMode], keymap: Keymap) -> tuple[str, ...]: + declaration = type(command) + return keymap.keys_for(KeymapRow.of(declaration, mode), declaration.default_keys()) + def require(self, reference: KeyInvocationId) -> KeyInvocation: try: return self.invocations[reference] @@ -191,11 +216,10 @@ def conflicts(self, mode: type[KeyMode]) -> tuple[KeyConflict, ...]: for defaults in catalog.scopes[mode]: # Inspect before Textual resolves clashes by replacing a conflicting binding. claims: dict[str, set[str]] = {} - for default_key, binding in BindingsMap(defaults): - if binding.id in rows: - keys = self.keys_for(rows[binding.id], (default_key,)) - for key in keys: - claims.setdefault(key, set()).add(binding.id) + for claim in defaults: + row = KeymapRow.of(claim.invocation, mode) + for key in self.keys_for(row, claim.keys): + claims.setdefault(key, set()).add(row.binding_id) for key, identities in claims.items(): claimed = tuple(sorted(identities)) for index, first in enumerate(claimed): diff --git a/src/toad/menus.py b/src/toad/menus.py index d4330f5fc..583a0c473 100644 --- a/src/toad/menus.py +++ b/src/toad/menus.py @@ -1,9 +1,189 @@ -from typing import NamedTuple +"""Block-menu declarations and their captured native operations.""" +from __future__ import annotations +import inspect +from abc import abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar -class MenuItem(NamedTuple): - """An entry in a Menu.""" +from agent_comms.declared_family import DeclaredFamily, FamilyMeta +from textual._callback import invoke +from textual.binding import ModeBinding +from textual.document._editing import EditingMode +from textual.keys import KeyDeclaration, KeyInvocation +from textual.widget import Widget +if TYPE_CHECKING: + from toad.line_blocks import LineLink + from toad.widgets.conversation import Conversation + from toad.widgets.menu import Menu + from toad.widgets.tool_call import ToolCall + + +class MenuDeclaration(FamilyMeta, KeyInvocation): + """The declaration, not captured instance parameters, owns keyboard identity.""" + + key: str | None description: str - action: str | None - key: str | None = None + + def key_identity(cls) -> str: + return KeyInvocation.qualified(cls) + + def key_title(cls) -> str: + return cls.description + + def key_summary(cls) -> str: + return inspect.cleandoc(cls.__doc__ or "") + + def key_system(cls) -> bool: + return False + + def default_keys(cls) -> tuple[str, ...]: + return () if cls.key is None else (cls.key,) + + def permits_invocation_overlap(cls, other: KeyInvocation) -> bool: + return other.overlaps_declaration(cls) + + def overlaps_declaration(cls, other: type[KeyDeclaration]) -> bool: + return cls.permits_key_overlap(other) + + +class MenuBinding(ModeBinding): + action: MenuCommand + + @property + def invocation(self) -> KeyInvocation: + return type(self.action) + + +class MenuCommand(DeclaredFamily, metaclass=MenuDeclaration, affix="Command"): + """Every block operation checks the captured menu context before acting.""" + + key: ClassVar[str | None] = None + description: ClassVar[str] + + @classmethod + def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: + return False + + def bindings(self, mode: type[EditingMode]) -> tuple[ModeBinding, ...]: + return tuple(binding for key in type(self).default_keys() + for binding in MenuBinding(key=key, action=self, mode=EditingMode, + description=self.description, show=False).for_mode(mode)) + + def __call__(self, menu: Menu) -> None: + menu.activate_command(self) + + async def run(self, owner: Menu) -> None: + from toad.session_scope import SessionScope + + conversation = owner.conversation + if (conversation.query_ancestor(SessionScope).is_current + and conversation.navigation.accepts_menu_owner(owner.block)): + await invoke(self.apply, conversation) + + @abstractmethod + def apply(self, conversation: Conversation) -> object: ... + + +class CopyClipboardCommand(MenuCommand): + key = "c" + description = "[u]C[/]opy to clipboard" + + def apply(self, conversation: Conversation) -> None: + conversation.action_copy_to_clipboard() + + +class CopyPromptCommand(MenuCommand): + key = "p" + description = "Co[u]p[/u]y to prompt" + + def apply(self, conversation: Conversation) -> None: + conversation.action_copy_to_prompt() + + +class ExportSvgCommand(MenuCommand): + key = "v" + description = "Open as S[u]V[/]G" + + def apply(self, conversation: Conversation) -> None: + conversation.action_export_to_svg() + + +class MaximizeBlockCommand(MenuCommand): + key = "m" + description = "[u]M[/u]aximize" + + def apply(self, conversation: Conversation) -> None: + conversation.action_maximize_block() + + +class CopyThinkingCommand(CopyClipboardCommand): + key = "y" + description = "Copy thinking" + + +@dataclass(frozen=True) +class FocusBlockCommand(MenuCommand): + block: Widget + key = "f" + description = "Focus" + + def apply(self, conversation: Conversation) -> None: + self.block.focus() + + +class InterruptCommand(MenuCommand): + key = "i" + description = "Interrupt" + + async def apply(self, conversation: Conversation) -> None: + from toad.conversation_actions import InterruptAction + + command = InterruptAction() + if command.available(conversation): + await command.apply(conversation) + + +class ExpansionCommand(MenuCommand, declared_name=None): + @classmethod + def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: + return issubclass(other, ExpansionCommand) + + +@dataclass(frozen=True) +class CollapseCommand(ExpansionCommand): + block: ToolCall + key = "x" + description = "Collapse" + + def apply(self, conversation: Conversation) -> None: + self.block.collapse_block() + + +@dataclass(frozen=True) +class ExpandCommand(ExpansionCommand): + block: ToolCall + key = "x" + description = "Expand" + + def apply(self, conversation: Conversation) -> None: + self.block.expand_block() + + +@dataclass(frozen=True) +class OpenLinkCommand(MenuCommand, declared_name=None): + link: LineLink + + def apply(self, conversation: Conversation) -> None: + conversation.transcript.binding.history.action_open(self.link) + + +class OpenSenderCommand(OpenLinkCommand): + key = "s" + description = "Open [u]s[/u]ender" + + +class OpenDestinationCommand(OpenLinkCommand): + key = "d" + description = "Open [u]d[/u]estination" diff --git a/src/toad/message_rows.py b/src/toad/message_rows.py index 940143c2a..c10b3ec3f 100644 --- a/src/toad/message_rows.py +++ b/src/toad/message_rows.py @@ -21,7 +21,7 @@ from agent_comms.routing import MessageRoute from toad.inline_spans import inline_spans -from toad.menus import MenuItem +from toad.menus import MenuCommand, OpenDestinationCommand, OpenSenderCommand from toad.line_blocks import ( MarkdownBody, Divider, IncomingRole, LineBlock, LineLink, LineRole, NameRole, OpenHistoricalTarget, OpenTarget, OutgoingRole, Span, Spans, @@ -120,10 +120,10 @@ def destination(self) -> LineLink: return self.name(self.route.targets[0]).link @property - def menu(self) -> tuple[MenuItem, ...]: + def menu(self) -> tuple[MenuCommand, ...]: """What the cursor's menu offers on this message, wherever it is drawn.""" - return (MenuItem("Open [u]s[/u]ender", "block.open_sender", "s"), - *(MenuItem("Open [u]d[/u]estination", "block.open_destination", "d"),) * bool(self.route.targets)) + return (OpenSenderCommand(self.sender), + *(OpenDestinationCommand(self.destination) for _ in self.route.targets[:1])) @property def route_text(self) -> str: diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index a78c40c20..167e01e0e 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -44,19 +44,6 @@ class Action(KeyDeclaration[Context], Command, DeclaredFamily, Generic[Context], def default_title(cls) -> str: return cls.declared_name.replace("_", " ").capitalize() - @classmethod - def declarations(cls): - """Each nested family retains its own declaration registry.""" - pending = [cls] - families = set() - while pending: - declaration = pending.pop() - pending.extend(declaration.__subclasses__()) - family = declaration.family_root - if family not in families: - families.add(family) - yield from family.members_with(cls) - @classmethod def summary(cls) -> str: """The nearest docstring a member declares; the family roots document code, not keys.""" diff --git a/src/toad/widgets/agent_thought.py b/src/toad/widgets/agent_thought.py index a22e81d82..3028f82b8 100644 --- a/src/toad/widgets/agent_thought.py +++ b/src/toad/widgets/agent_thought.py @@ -5,7 +5,7 @@ from typing import Iterable from toad.line_blocks import LineBlock -from toad.menus import MenuItem +from toad.menus import CopyThinkingCommand, MenuCommand from toad.widgets.agent_activity import AgentActivityPresentation from toad.widgets.committed_presentation import SnapshotPresentation from toad.widgets.line_markdown import LineMarkdown @@ -44,5 +44,5 @@ async def append_fragment(self, fragment: str) -> None: async def finish_stream(self) -> None: pass - def get_block_menu(self) -> Iterable[MenuItem]: - yield MenuItem("Copy thinking", "block.copy", "y") + def get_block_menu(self) -> Iterable[MenuCommand]: + yield CopyThinkingCommand() diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 69493d711..c8a95678a 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -74,7 +74,7 @@ from toad.input_history import InputHistories from toad.layout import trim_trailing_margin from toad.live_output import LiveOutput -from toad.menus import MenuItem +from toad.menus import CopyClipboardCommand, CopyPromptCommand, ExportSvgCommand, MaximizeBlockCommand from toad.preferences import SETTINGS from toad.private_native_cursor import CursorStatus, HiddenCursorStatus from toad.resource_release import AsyncResourceRelease @@ -1350,12 +1350,7 @@ async def agent_turn_over(self, stop_reason: type[StopReason] | None) -> None: @on(Menu.OptionSelected) async def on_menu_option_selected(self, event: Menu.OptionSelected) -> None: event.stop() - if ( - self.query_ancestor(SessionScope).is_current - and event.action is not None - and self.navigation.accepts_menu_owner(event.owner) - ): - await self.run_action(event.action, {"block": event.owner}) + await event.command.run(event.menu) self.call_after_refresh(self.cursor.refresh) @handles(CurrentWorkingDirectoryChanged) @@ -1924,11 +1919,11 @@ def jump_to_latest(self) -> None: async def action_select_block(self) -> None: cursor = self.navigation.cursor for block in cursor.selected_widgets: - menu_options = [MenuItem('[u]C[/]opy to clipboard', 'copy_to_clipboard', 'c'), MenuItem('Co[u]p[/u]y to prompt', 'copy_to_prompt', 'p'), MenuItem('Open as S[u]V[/]G', 'export_to_svg', 'v')] + commands = [CopyClipboardCommand(), CopyPromptCommand(), ExportSvgCommand()] if cursor.allow_maximize: - menu_options.append(MenuItem('[u]M[/u]aximize', 'maximize_block', 'm')) - menu_options.extend(cursor.get_block_menu()) - menu = Menu(block, menu_options, focus_owner=self.window) + commands.append(MaximizeBlockCommand()) + commands.extend(cursor.get_block_menu()) + menu = Menu(self, block, commands, focus_owner=self.window) region = cursor.region menu.offset = Offset(1, region.y if region is not None else self.window.region.y) await self.mount(menu) diff --git a/src/toad/widgets/linked_rows.py b/src/toad/widgets/linked_rows.py index c261d84a0..5cc76ee9f 100644 --- a/src/toad/widgets/linked_rows.py +++ b/src/toad/widgets/linked_rows.py @@ -22,7 +22,7 @@ from toad.core_event_carrier import CoreEventReceiver from toad.frame_presentation import AnimationEffects from toad.line_blocks import LineLink, OpenHistoricalTarget, OpenReference, OpenTarget -from toad.menus import MenuItem +from toad.menus import MenuCommand from toad.message_rows import MessageRow from toad.navigation_target import NavigationTarget from toad.project_link import ProjectLink @@ -107,7 +107,7 @@ def action_open_destination(self) -> None: class MessageRowBlock(OpensMessageNames): """A widget that is one bus message under the cursor: it offers the message's menu.""" - def get_block_menu(self) -> tuple[MenuItem, ...]: + def get_block_menu(self) -> tuple[MenuCommand, ...]: return self.message_row.menu diff --git a/src/toad/widgets/menu.py b/src/toad/widgets/menu.py index 263cdeb77..e1f2a000b 100644 --- a/src/toad/widgets/menu.py +++ b/src/toad/widgets/menu.py @@ -1,20 +1,27 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING + +from textual.binding import BindingsMap +from textual.document._editing import EditingMode from textual import on from textual.app import ComposeResult from toad.widget_actions import DismissibleControl from textual.message import Message from textual.widgets import ListView, Label -from textual import events from textual.widget import Widget from textual.layouts.horizontal import HorizontalLayout -from toad.menus import MenuItem +from toad.menus import MenuCommand +from toad.keymap import KeymapCatalog from toad.widgets.selection import SelectionListItem from toad.tone_paint import UntintedTonePaint +if TYPE_CHECKING: + from toad.widgets.conversation import Conversation + class NonSelectableLabel(Label): @@ -25,15 +32,20 @@ class MenuOption(SelectionListItem): ALLOW_SELECT = False DEFAULT_LAYOUT = HorizontalLayout - def __init__(self, action: str | None, description: str, key: str | None) -> None: - self._action = action - self._description = description - self._key = key - super().__init__(classes="-has-key" if key else "-no_key") + def __init__(self, command: MenuCommand) -> None: + self.command = command + super().__init__() def compose(self) -> ComposeResult: - yield NonSelectableLabel(self._key or " ", id="key") - yield NonSelectableLabel(self._description, id="description") + yield NonSelectableLabel(" ", id="key") + yield NonSelectableLabel(self.command.description, id="description") + + def refresh_key(self) -> None: + keys = KeymapCatalog.menu_keys(self.command, type(self.app.settings.ui.key_mode), + self.app.settings.ui.keymap) + self.query_one("#key", NonSelectableLabel).update(", ".join(keys) or " ") + self.set_class(bool(keys), "-has-key") + self.set_class(not keys, "-no_key") class Menu(DismissibleControl, UntintedTonePaint, ListView, can_focus=True): @@ -76,27 +88,42 @@ class OptionSelected(Message): """The user selected on of the options.""" menu: Menu - owner: Widget - action: str | None + command: MenuCommand - def __init__(self, owner: Widget, options: list[MenuItem], *args, focus_owner: Widget, **kwargs) -> None: - self._owner = owner + def __init__(self, conversation: Conversation, owner: Widget, + commands: list[MenuCommand], *args, focus_owner: Widget, **kwargs) -> None: + self.conversation = conversation + self.block = owner self._focus_owner = focus_owner - self._options = sorted(options, key=lambda option: option.key is None) + self._commands = tuple(sorted(commands, key=lambda command: not type(command).default_keys())) super().__init__(*args, **kwargs) + def binding_map(self, mode: type[EditingMode]) -> BindingsMap: + return BindingsMap.merge((super().binding_map(mode), BindingsMap( + binding for command in self._commands for binding in command.bindings(mode)))) + def _insert_options(self) -> None: self.extend( - MenuOption(menu_item.action, menu_item.description, menu_item.key) - for menu_item in self._options + MenuOption(command) for command in self._commands ) def on_mount(self) -> None: self._insert_options() + self.screen.bindings_updated_signal.subscribe(self, self.refresh_keys) + self.call_after_refresh(self.refresh_keys) + + def on_unmount(self) -> None: + self.screen.bindings_updated_signal.unsubscribe(self) + + def refresh_keys(self, _screen: object = None) -> None: + for option in self.query(MenuOption): + option.refresh_key() async def activate_index(self, index: int) -> None: - action = self._options[index].action - self.post_message(self.OptionSelected(self, self._owner, action)) + self.activate_command(self._commands[index]) + + def activate_command(self, command: MenuCommand) -> None: + self.post_message(self.OptionSelected(self, command)) self.dismiss_control() def dismiss_control(self) -> None: @@ -110,15 +137,6 @@ def dismiss_control(self) -> None: def on_blur(self) -> None: self.dismiss_control() - @on(events.Key) - async def on_key(self, event: events.Key) -> None: - for index, option in enumerate(self._options): - if event.key == option.key: - self.index = index - event.stop() - await self.activate_index(index) - break - @on(ListView.Selected) async def on_list_view_selected(self, event: ListView.Selected) -> None: event.stop() diff --git a/src/toad/widgets/shell_terminal.py b/src/toad/widgets/shell_terminal.py index db1bf9a0c..c019e42a2 100644 --- a/src/toad/widgets/shell_terminal.py +++ b/src/toad/widgets/shell_terminal.py @@ -5,7 +5,7 @@ from textual.dom import DOMNode from textual.message import Message -from toad.menus import MenuItem +from toad.menus import FocusBlockCommand, InterruptCommand, MenuCommand from toad.widgets.terminal import Terminal @@ -28,10 +28,10 @@ class Interrupt(Message): def control(self) -> DOMNode: return self.terminal - def get_block_menu(self) -> Iterable[MenuItem]: + def get_block_menu(self) -> Iterable[MenuCommand]: if not self.is_finalized: - yield MenuItem("Interrupt", "interrupt", "i") - yield MenuItem("Focus", f"focus_block({self.id!r})", "f") + yield InterruptCommand() + yield FocusBlockCommand(self) def get_clipboard_text(self) -> str | None: return "\n".join(line.content.plain for line in self.state.buffer.lines) diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index 2b27801fd..4234020bf 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -15,7 +15,7 @@ from metaclass_registry.mro_dispatch import handles from toad.acp.status import ToolCallStatus from toad.line_blocks import Body, ToggleDetails, ToolOutputRole -from toad.menus import MenuItem +from toad.menus import CollapseCommand, ExpandCommand, MenuCommand from agent_comms.ui_model.message_category import MessageCategory from toad.widgets.committed_presentation import CAPTURED_CLAIM, CapturedClaim, SnapshotPresentation, ToolOutputClaim @@ -129,11 +129,11 @@ def update_tool_call(self, tool_call: ToolCallStatus, *, output_parts=None) -> A self.query_one("#tool-title", LineMarkdown).update(self.current_status.title) return self.output.sync() - def get_block_menu(self) -> Iterable[MenuItem]: + def get_block_menu(self) -> Iterable[MenuCommand]: if self.expanded: - yield MenuItem("Collapse", "block.collapse", "x") + yield CollapseCommand(self) else: - yield MenuItem("Expand", "block.expand", "x") + yield ExpandCommand(self) def action_collapse(self) -> None: self.set_expanded(False) diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 77489a592..6281db8a1 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -43,7 +43,7 @@ UserRole, ) from toad.markdown_preparation import PreparedMarkdownPart -from toad.menus import MenuItem +from toad.menus import MenuCommand from toad.message_rows import MarkdownMessageStyle, MessageRow, WireMessageStyle from toad.notification_status import NotificationStatus, UncheckedNotifications from toad.widgets.agent_activity import AgentActivityPresentation @@ -415,7 +415,7 @@ def clipboard_text(self) -> str | None: return "\n\n".join(event.text for event in self.events if isinstance(event, TextTranscript)) or None @property - def block_menu(self) -> tuple[MenuItem, ...]: + def block_menu(self) -> tuple[MenuCommand, ...]: """What the cursor's menu offers on this fragment, besides copying.""" return () diff --git a/uv.lock b/uv.lock index 17af5a2c9..be62a58e4 100644 --- a/uv.lock +++ b/uv.lock @@ -1337,7 +1337,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#bea0d377b9bc414dc377f1ebcbb51405fc7b0e03" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#40873a82c48f723e1d5a5e2ef0809bfce4e8e459" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From eba46daf68e7d9588e78cf1d2ddea391d05a12b9 Mon Sep 17 00:00:00 2001 From: toad-keymap Date: Fri, 30 Oct 2026 02:21:56 -0400 Subject: [PATCH 05/18] fix: derive Menu catalog claims from their native scope owner --- src/toad/keymap.py | 35 +++++++++-------------------------- src/toad/widgets/menu.py | 10 +++++++++- uv.lock | 2 +- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/toad/keymap.py b/src/toad/keymap.py index e7ec4b4fa..e4b65b0ea 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, replace -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Mapping from functools import cache from importlib import import_module from pkgutil import walk_packages @@ -15,7 +15,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import NominalSpelling, NominalText -from textual.binding import Binding, BindingsMap +from textual.binding import KeymapClaim from textual.dom import DOMNode from textual.keys import KeyInvocation from textual.screen import ModalScreen @@ -94,19 +94,6 @@ def __str__(self) -> str: f"({FamilyChoices(KeyMode).label(self.first.mode)})") -@dataclass(frozen=True) -class KeymapClaim: - """A detached default claim, with its original nominal invocation intact.""" - - invocation: KeyInvocation - keys: tuple[str, ...] - - @classmethod - def from_bindings(cls, bindings: Iterable[Binding]) -> Iterator[KeymapClaim]: - return (cls(invocation, (key,)) for key, binding in BindingsMap(bindings) - for invocation in binding.invocations()) - - @dataclass(frozen=True) class KeymapCatalog: """A detached view of the same concrete bindings the native scopes dispatch.""" @@ -123,20 +110,16 @@ def capture(cls, modes: tuple[type[KeyMode], ...]) -> KeymapCatalog: keys: dict[tuple[KeyInvocationId, type[KeyMode]], dict[str, None]] = {} for mode in modes: native_mode = mode().binding_mode - application = tuple(binding for declaration in ApplicationAction.declarations() - for binding in declaration.exclusive_bindings(native_mode)) - owned = [tuple(KeymapClaim.from_bindings(application))] + application = tuple(KeymapClaim.from_bindings( + binding for declaration in ApplicationAction.declarations() + for binding in declaration.exclusive_bindings(native_mode) + )) + owned = [application] for owner in DOMNode.contexts(): - defaults = tuple(binding for _, binding in owner.binding_defaults(native_mode)) + defaults = owner.keymap_claims(native_mode) if not issubclass(owner, ModalScreen): defaults = (*defaults, *application) - owned.append(tuple(KeymapClaim.from_bindings(defaults))) - from toad.widgets.menu import Menu - owned.append(( - *KeymapClaim.from_bindings(binding for _, binding in Menu.binding_defaults(native_mode)), - *(KeymapClaim(declaration, declaration.default_keys()) - for declaration in MenuCommand.members_with(MenuCommand)), - )) + owned.append(defaults) scopes[mode] = tuple(owned) for defaults in owned: for claim in defaults: diff --git a/src/toad/widgets/menu.py b/src/toad/widgets/menu.py index e1f2a000b..381be2082 100644 --- a/src/toad/widgets/menu.py +++ b/src/toad/widgets/menu.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from textual.binding import BindingsMap +from textual.binding import BindingsMap, KeymapClaim from textual.document._editing import EditingMode from textual import on @@ -98,6 +98,14 @@ def __init__(self, conversation: Conversation, owner: Widget, self._commands = tuple(sorted(commands, key=lambda command: not type(command).default_keys())) super().__init__(*args, **kwargs) + @classmethod + def keymap_claims(cls, mode: type[EditingMode]) -> tuple[KeymapClaim, ...]: + return ( + *super().keymap_claims(mode), + *(KeymapClaim(declaration, declaration.default_keys()) + for declaration in MenuCommand.members_with(MenuCommand)), + ) + def binding_map(self, mode: type[EditingMode]) -> BindingsMap: return BindingsMap.merge((super().binding_map(mode), BindingsMap( binding for command in self._commands for binding in command.bindings(mode)))) diff --git a/uv.lock b/uv.lock index be62a58e4..36b593819 100644 --- a/uv.lock +++ b/uv.lock @@ -1337,7 +1337,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#40873a82c48f723e1d5a5e2ef0809bfce4e8e459" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#e9f681e40c9923edec45990552e082276b23cc0b" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 7e28260d3c353acf1401d5fc4abcdc7a0252f8e4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:08:21 -0400 Subject: [PATCH 06/18] Declare keyed Menu defaults and close navigation imports --- src/toad/conversation_actions.py | 3 +- src/toad/menus.py | 47 ++++++++++++++++-------------- src/toad/widgets/conversation.py | 19 +++++++++--- src/toad/widgets/declared_table.py | 3 +- 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/toad/conversation_actions.py b/src/toad/conversation_actions.py index 18c023e13..b8baa8a56 100644 --- a/src/toad/conversation_actions.py +++ b/src/toad/conversation_actions.py @@ -46,8 +46,7 @@ def available(self, conversation: Conversation) -> bool: return bool(conversation.terminal_action_state) async def apply(self, conversation: Conversation) -> None: - await conversation.shell.interrupt() - conversation.feedback.flash("Command interrupted", style="success") + await conversation.interrupt_shell() diff --git a/src/toad/menus.py b/src/toad/menus.py index 583a0c473..0231bee01 100644 --- a/src/toad/menus.py +++ b/src/toad/menus.py @@ -23,7 +23,6 @@ class MenuDeclaration(FamilyMeta, KeyInvocation): """The declaration, not captured instance parameters, owns keyboard identity.""" - key: str | None description: str def key_identity(cls) -> str: @@ -38,9 +37,6 @@ def key_summary(cls) -> str: def key_system(cls) -> bool: return False - def default_keys(cls) -> tuple[str, ...]: - return () if cls.key is None else (cls.key,) - def permits_invocation_overlap(cls, other: KeyInvocation) -> bool: return other.overlaps_declaration(cls) @@ -59,9 +55,12 @@ def invocation(self) -> KeyInvocation: class MenuCommand(DeclaredFamily, metaclass=MenuDeclaration, affix="Command"): """Every block operation checks the captured menu context before acting.""" - key: ClassVar[str | None] = None description: ClassVar[str] + @classmethod + def default_keys(cls) -> tuple[str, ...]: + return () + @classmethod def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: return False @@ -75,10 +74,8 @@ def __call__(self, menu: Menu) -> None: menu.activate_command(self) async def run(self, owner: Menu) -> None: - from toad.session_scope import SessionScope - conversation = owner.conversation - if (conversation.query_ancestor(SessionScope).is_current + if (conversation.is_current_session and conversation.navigation.accepts_menu_owner(owner.block)): await invoke(self.apply, conversation) @@ -86,7 +83,17 @@ async def run(self, owner: Menu) -> None: def apply(self, conversation: Conversation) -> object: ... -class CopyClipboardCommand(MenuCommand): +class KeyedMenuCommand(MenuCommand, declared_name=None): + """A command with a declared default key supplies it through inheritance.""" + + key: ClassVar[str] + + @classmethod + def default_keys(cls) -> tuple[str, ...]: + return (cls.key,) + + +class CopyClipboardCommand(KeyedMenuCommand): key = "c" description = "[u]C[/]opy to clipboard" @@ -94,7 +101,7 @@ def apply(self, conversation: Conversation) -> None: conversation.action_copy_to_clipboard() -class CopyPromptCommand(MenuCommand): +class CopyPromptCommand(KeyedMenuCommand): key = "p" description = "Co[u]p[/u]y to prompt" @@ -102,7 +109,7 @@ def apply(self, conversation: Conversation) -> None: conversation.action_copy_to_prompt() -class ExportSvgCommand(MenuCommand): +class ExportSvgCommand(KeyedMenuCommand): key = "v" description = "Open as S[u]V[/]G" @@ -110,7 +117,7 @@ def apply(self, conversation: Conversation) -> None: conversation.action_export_to_svg() -class MaximizeBlockCommand(MenuCommand): +class MaximizeBlockCommand(KeyedMenuCommand): key = "m" description = "[u]M[/u]aximize" @@ -124,7 +131,7 @@ class CopyThinkingCommand(CopyClipboardCommand): @dataclass(frozen=True) -class FocusBlockCommand(MenuCommand): +class FocusBlockCommand(KeyedMenuCommand): block: Widget key = "f" description = "Focus" @@ -133,19 +140,15 @@ def apply(self, conversation: Conversation) -> None: self.block.focus() -class InterruptCommand(MenuCommand): +class InterruptCommand(KeyedMenuCommand): key = "i" description = "Interrupt" async def apply(self, conversation: Conversation) -> None: - from toad.conversation_actions import InterruptAction - - command = InterruptAction() - if command.available(conversation): - await command.apply(conversation) + await conversation.interrupt_shell() -class ExpansionCommand(MenuCommand, declared_name=None): +class ExpansionCommand(KeyedMenuCommand, declared_name=None): @classmethod def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: return issubclass(other, ExpansionCommand) @@ -179,11 +182,11 @@ def apply(self, conversation: Conversation) -> None: conversation.transcript.binding.history.action_open(self.link) -class OpenSenderCommand(OpenLinkCommand): +class OpenSenderCommand(OpenLinkCommand, KeyedMenuCommand): key = "s" description = "Open [u]s[/u]ender" -class OpenDestinationCommand(OpenLinkCommand): +class OpenDestinationCommand(OpenLinkCommand, KeyedMenuCommand): key = "d" description = "Open [u]d[/u]estination" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index c8a95678a..f771e667f 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -439,6 +439,11 @@ class ConversationSessionBinding( _unpublished_presentation: ThreadPresentation = NoThreadPresentation() """An observed presentation whose transcript refresh waits for this view to be shown.""" + @property + def is_current_session(self) -> bool: + """Whether this conversation's owning session is still selected.""" + return self.query_ancestor(SessionScope).is_current + @property def operational_sources(self): return self.query_ancestor(SessionScope).presentation.sources @@ -481,7 +486,7 @@ def _no_thread_source(self, presentation: NoThreadPresentation) -> object: def _publish_thread_source(self, presentation: ThreadPresentation) -> None: if not self.transcript.readable: return - if self.query_ancestor(SessionScope).is_current: + if self.is_current_session: from toad.transcript_publication import ObservedSourcePublication self._unpublished_presentation = NoThreadPresentation() @@ -493,7 +498,7 @@ def _publish_thread_source(self, presentation: ThreadPresentation) -> None: @handles(core_events.SessionSelected) async def _thread_view_selected(self, event: core_events.SessionSelected) -> None: - if not self.query_ancestor(SessionScope).is_current: + if not self.is_current_session: return self.refresh_thread_status() self.goal_observation.invalidate() @@ -1090,6 +1095,12 @@ def focus_terminal(self) -> None: if (terminal := self._terminal) is not None: terminal.focus() + async def interrupt_shell(self) -> None: + """Menu and keyboard interruption share the live terminal's eligibility.""" + if self.terminal_action_state: + await self.shell.interrupt() + self.feedback.flash("Command interrupted", style="success") + @cached_property def navigation(self) -> ContentNavigation: return ContentNavigation(self.contents) @@ -1684,7 +1695,7 @@ def on_mouse_down(self, event: events.MouseDown) -> None: def on_click(self, event: events.Click) -> None: # A queued history click may arrive after its logical source retired. - if not self.query_ancestor(SessionScope).is_current: + if not self.is_current_session: return if ( self._mouse_down_offset is not None @@ -1999,7 +2010,7 @@ def _present_block_cursor( if ( self.is_attached and not self.is_leaving - and self.query_ancestor(SessionScope).is_current + and self.is_current_session and self.navigation.cursor is cursor ): show(cursor) diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index 8c76d636c..1d1450425 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -8,12 +8,13 @@ from agent_comms.disclosure_text import AnonymousKey, DisclosureKey, ViewTable from textual.content import Content +from textual.navigation import MovesCursor from textual.style import Style from textual.widgets import DataTable from textual.widgets._data_table import RowKey from ui_theme.scheme import ForegroundColour, ThemeColour -from toad.widget_actions import MovesCursor, OpensSelected +from toad.widget_actions import OpensSelected class SelectionView[R]: From bf1ba2a0bcd7bc149f0b4cddde5c8e662862cbf0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 13:41:26 -0400 Subject: [PATCH 07/18] Use the native declaration class contract and inherited table navigation --- src/toad/menus.py | 36 ++++++++++++++++++------------ src/toad/widget_actions.py | 22 +++++++++--------- src/toad/widgets/declared_table.py | 3 +-- src/toad/widgets/grid_select.py | 9 +++++++- src/toad/widgets/menu.py | 3 ++- uv.lock | 2 +- 6 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/toad/menus.py b/src/toad/menus.py index 0231bee01..ace088f33 100644 --- a/src/toad/menus.py +++ b/src/toad/menus.py @@ -1,4 +1,5 @@ """Block-menu declarations and their captured native operations.""" + from __future__ import annotations import inspect @@ -6,13 +7,15 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar -from agent_comms.declared_family import DeclaredFamily, FamilyMeta +from agent_comms.declared_family import DeclaredFamily from textual._callback import invoke from textual.binding import ModeBinding from textual.document._editing import EditingMode -from textual.keys import KeyDeclaration, KeyInvocation +from textual.keys import KeyDeclarationType, KeyInvocation from textual.widget import Widget +from toad.widget_actions import KeyFamilyMeta + if TYPE_CHECKING: from toad.line_blocks import LineLink from toad.widgets.conversation import Conversation @@ -20,7 +23,7 @@ from toad.widgets.tool_call import ToolCall -class MenuDeclaration(FamilyMeta, KeyInvocation): +class MenuDeclaration(KeyFamilyMeta, KeyInvocation): """The declaration, not captured instance parameters, owns keyboard identity.""" description: str @@ -40,7 +43,7 @@ def key_system(cls) -> bool: def permits_invocation_overlap(cls, other: KeyInvocation) -> bool: return other.overlaps_declaration(cls) - def overlaps_declaration(cls, other: type[KeyDeclaration]) -> bool: + def overlaps_declaration(cls, other: KeyDeclarationType) -> bool: return cls.permits_key_overlap(other) @@ -61,22 +64,27 @@ class MenuCommand(DeclaredFamily, metaclass=MenuDeclaration, affix="Command"): def default_keys(cls) -> tuple[str, ...]: return () - @classmethod - def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: - return False - def bindings(self, mode: type[EditingMode]) -> tuple[ModeBinding, ...]: - return tuple(binding for key in type(self).default_keys() - for binding in MenuBinding(key=key, action=self, mode=EditingMode, - description=self.description, show=False).for_mode(mode)) + return tuple( + binding + for key in type(self).default_keys() + for binding in MenuBinding( + key=key, + action=self, + mode=EditingMode, + description=self.description, + show=False, + ).for_mode(mode) + ) def __call__(self, menu: Menu) -> None: menu.activate_command(self) async def run(self, owner: Menu) -> None: conversation = owner.conversation - if (conversation.is_current_session - and conversation.navigation.accepts_menu_owner(owner.block)): + if conversation.is_current_session and conversation.navigation.accepts_menu_owner( + owner.block + ): await invoke(self.apply, conversation) @abstractmethod @@ -150,7 +158,7 @@ async def apply(self, conversation: Conversation) -> None: class ExpansionCommand(KeyedMenuCommand, declared_name=None): @classmethod - def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: + def permits_key_overlap(cls, other: KeyDeclarationType) -> bool: return issubclass(other, ExpansionCommand) diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index 167e01e0e..388ab7145 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -7,29 +7,31 @@ from functools import partialmethod from operator import or_ from types import MappingProxyType -from typing import TYPE_CHECKING, ClassVar, Generic, Literal, Self, TypeVar +from typing import ClassVar, Generic, Literal, Self, TypeVar from agent_comms.command import Command -from agent_comms.declared_family import DeclaredFamily +from agent_comms.declared_family import DeclaredFamily, FamilyMeta from agent_comms.field_codec import FieldCodec, FieldPlan from textual.binding import Binding, BindingsMap, CommandBinding, ModeBinding from textual import events from textual._activation import PrimaryActivation from textual._callback import invoke -from textual.keys import KeyDeclaration +from textual.keys import KeyDeclaration, KeyDeclarationType from textual.widgets.text_area import EditingMode from textual.dom import DOMNode from toad.app_node import ToadNode from copy import replace from dataclasses import dataclass -if TYPE_CHECKING: - from toad.app import ToadApp - Context = TypeVar("Context", bound=DOMNode) -class Action(KeyDeclaration[Context], Command, DeclaredFamily, Generic[Context], affix="Action"): +class KeyFamilyMeta(FamilyMeta, KeyDeclarationType): + """A named command family whose classes declare native key overlap.""" + + +class Action(KeyDeclaration[Context], Command, DeclaredFamily, Generic[Context], + metaclass=KeyFamilyMeta, affix="Action"): """Only the Textual boundary decodes an external action name. A member declares its keys, and its title and docstring are the only text any view shows for it.""" @@ -163,7 +165,7 @@ class ExclusiveKeys: """One declared family of mutually exclusive actions may share its default keys.""" @classmethod - def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: + def permits_key_overlap(cls, other: KeyDeclarationType) -> bool: family = next(base for base in cls.__mro__ if ExclusiveKeys in base.__bases__) return issubclass(other, family) @@ -179,7 +181,7 @@ def exclusive_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: return cls.bindings_for(mode) @classmethod - def menu(cls, app: ToadApp) -> tuple[tuple[ApplicationAction, str], ...]: + def menu(cls, app) -> tuple[tuple[ApplicationAction, str], ...]: """Every offered member under its label, in declaration order.""" return tuple((command, command.label(app)) for member in cls.members_with(cls) for command in member.offered(app)) @@ -188,7 +190,7 @@ def menu(cls, app: ToadApp) -> tuple[tuple[ApplicationAction, str], ...]: class VimWorkspaceAction(ApplicationAction, declared_name=None): """Workspace commands precede editor bindings only in idle modal command entry.""" - def key_available(self, app: ToadApp) -> bool: + def key_available(self, app) -> bool: if app.current_mode != "workspace": return False return (app.screen is app.workspace_screen diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index 1d1450425..e77c709d0 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -8,7 +8,6 @@ from agent_comms.disclosure_text import AnonymousKey, DisclosureKey, ViewTable from textual.content import Content -from textual.navigation import MovesCursor from textual.style import Style from textual.widgets import DataTable from textual.widgets._data_table import RowKey @@ -74,7 +73,7 @@ def place_cursor(self, table: DeclaredTable[R]) -> None: table.move_cursor(row=table.get_row_index(native), animate=False, scroll=False) -class DeclaredTable[R](MovesCursor, OpensSelected, DataTable[Content]): +class DeclaredTable[R](OpensSelected, DataTable[Content]): """Core owns columns and measurement; DataTable owns rows, geometry and repaint.""" def __init__(self, source: ViewTable[R]) -> None: diff --git a/src/toad/widgets/grid_select.py b/src/toad/widgets/grid_select.py index aba043a6a..aa9922248 100644 --- a/src/toad/widgets/grid_select.py +++ b/src/toad/widgets/grid_select.py @@ -11,7 +11,14 @@ from toad.widget_actions import MovesHorizontalCursor, Selects, VisibleActions -class GridSelect(VisibleActions, MovesCursor, MovesHorizontalCursor, Selects, containers.ItemGrid, can_focus=True): +class GridSelect( + VisibleActions, + MovesCursor, + MovesHorizontalCursor, + Selects, + containers.ItemGrid, + can_focus=True, +): FOCUS_ON_CLICK = False highlighted: reactive[int | None] = reactive(None) diff --git a/src/toad/widgets/menu.py b/src/toad/widgets/menu.py index 381be2082..152ccb3ae 100644 --- a/src/toad/widgets/menu.py +++ b/src/toad/widgets/menu.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING @@ -91,7 +92,7 @@ class OptionSelected(Message): command: MenuCommand def __init__(self, conversation: Conversation, owner: Widget, - commands: list[MenuCommand], *args, focus_owner: Widget, **kwargs) -> None: + commands: Sequence[MenuCommand], *args, focus_owner: Widget, **kwargs) -> None: self.conversation = conversation self.block = owner self._focus_owner = focus_owner diff --git a/uv.lock b/uv.lock index 31cb59c88..bffd9a937 100644 --- a/uv.lock +++ b/uv.lock @@ -1338,7 +1338,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#832b0f6d93500c8f250f41c8ceee2337a3839ba4" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#76f9ef770bfa0630d09aeaee931ca4356996120b" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 6c1731ee749604c3b84cfe27d44478c0fbb6e020 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 17:42:06 -0400 Subject: [PATCH 08/18] Migrate the block-menu test to MenuCommand and share ExclusiveKeys for expansion ExpansionCommand restated the overlap check ExclusiveKeys already owns (widget_actions.py:177), so it now inherits the mixin and the copied issubclass goes. test_block_menu_and_copy imported the deleted MenuItem and failed at collection; its extra block now declares a keyed command. tests/textual/test_block_menu_and_copy.py: 2 passed. Co-Authored-By: Claude Opus 5.5 --- src/toad/menus.py | 8 +++----- tests/textual/test_block_menu_and_copy.py | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/toad/menus.py b/src/toad/menus.py index ace088f33..ec1edeca5 100644 --- a/src/toad/menus.py +++ b/src/toad/menus.py @@ -14,7 +14,7 @@ from textual.keys import KeyDeclarationType, KeyInvocation from textual.widget import Widget -from toad.widget_actions import KeyFamilyMeta +from toad.widget_actions import ExclusiveKeys, KeyFamilyMeta if TYPE_CHECKING: from toad.line_blocks import LineLink @@ -156,10 +156,8 @@ async def apply(self, conversation: Conversation) -> None: await conversation.interrupt_shell() -class ExpansionCommand(KeyedMenuCommand, declared_name=None): - @classmethod - def permits_key_overlap(cls, other: KeyDeclarationType) -> bool: - return issubclass(other, ExpansionCommand) +class ExpansionCommand(ExclusiveKeys, KeyedMenuCommand, declared_name=None): + """Collapse and expand alternate on one block, so they share their key.""" @dataclass(frozen=True) diff --git a/tests/textual/test_block_menu_and_copy.py b/tests/textual/test_block_menu_and_copy.py index a2933089e..490ef3f97 100644 --- a/tests/textual/test_block_menu_and_copy.py +++ b/tests/textual/test_block_menu_and_copy.py @@ -14,7 +14,9 @@ from toad.widgets.conversation import Conversation, FocusPromptAction from textual.keys import KeyDeclaration from textual.document._editing import EditingMode -from toad.menus import MenuItem +from dataclasses import dataclass + +from toad.menus import KeyedMenuCommand from toad.widgets.menu import Menu from toad.widgets.tool_call import ToolCall from toad.widgets.user_input import UserInput @@ -25,15 +27,22 @@ from toad.project_link import DirectProjectFileLink +@dataclass(frozen=True) +class ChooseDeclaredCommand(KeyedMenuCommand): + block: Static + key = "z" + description = "Apply declared action" + + def apply(self, conversation): + self.block.update("DECLARED_ACTION_PAINT") + + class DeclaredExtraBlock(ConversationBlock, Static): def get_clipboard_text(self): return "DECLARED_COPY café 界" def get_block_menu(self): - return (MenuItem("Apply declared action", "block.choose", "z"),) - - def action_choose(self): - self.update("DECLARED_ACTION_PAINT") + return (ChooseDeclaredCommand(self),) @pytest.fixture From 591f133dd8b87d499afe54f639cf3e63ba5459ca Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 18:24:14 -0400 Subject: [PATCH 09/18] Give block-menu commands declared contracts instead of checker-only imports menus.py imported Conversation, Menu, ToolCall and LineLink only under TYPE_CHECKING. Conversation and Menu now inherit MenuConversation and MenuOwner, which state what a command may ask of them. LineLink is a real import. CollapseCommand and ExpandCommand move to BlockContent, which owns the methods they call. The session and navigation check, and the link route through the transcript, move onto Conversation behind accepts_menu_owner and open_line_link. Delete present_navigation_binding: no parent defines it and nothing calls it. Action.declarations narrows the native registry's type to the family asking. MenuOption inherits ToadNode to see the application's settings. Co-Authored-By: Claude Opus 5.5 --- src/toad/block_content.py | 30 +++++++++- src/toad/menus.py | 94 +++++++++++++++++--------------- src/toad/widget_actions.py | 13 +++-- src/toad/widgets/conversation.py | 13 ++++- src/toad/widgets/menu.py | 14 ++--- src/toad/widgets/tool_call.py | 3 +- 6 files changed, 103 insertions(+), 64 deletions(-) diff --git a/src/toad/block_content.py b/src/toad/block_content.py index c5c5f808f..e6c11a1d4 100644 --- a/src/toad/block_content.py +++ b/src/toad/block_content.py @@ -1,10 +1,14 @@ """Declared block capabilities shared by scene and original source consumers.""" +from __future__ import annotations + from collections.abc import Iterable +from dataclasses import dataclass from functools import cached_property from textual.widgets import _markdown as native_markdown -from toad.menus import MenuCommand +from toad.menus import KeyedMenuCommand, MenuCommand, MenuConversation +from toad.widget_actions import ExclusiveKeys class BlockContent: @@ -39,6 +43,30 @@ def collapse_block(self) -> None: pass +class ExpansionCommand(ExclusiveKeys, KeyedMenuCommand, declared_name=None): + """Collapse and expand alternate on one block, so they share their key.""" + + +@dataclass(frozen=True) +class CollapseCommand(ExpansionCommand): + block: BlockContent + key = "x" + description = "Collapse" + + def apply(self, conversation: MenuConversation) -> None: + self.block.collapse_block() + + +@dataclass(frozen=True) +class ExpandCommand(ExpansionCommand): + block: BlockContent + key = "x" + description = "Expand" + + def apply(self, conversation: MenuConversation) -> None: + self.block.expand_block() + + class MarkdownBlockContent(BlockContent): """Declared native blocks share source-copy and menu capabilities.""" diff --git a/src/toad/menus.py b/src/toad/menus.py index ec1edeca5..8841613d7 100644 --- a/src/toad/menus.py +++ b/src/toad/menus.py @@ -5,7 +5,7 @@ import inspect from abc import abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar from agent_comms.declared_family import DeclaredFamily from textual._callback import invoke @@ -14,13 +14,43 @@ from textual.keys import KeyDeclarationType, KeyInvocation from textual.widget import Widget -from toad.widget_actions import ExclusiveKeys, KeyFamilyMeta +from toad.line_blocks import LineLink +from toad.widget_actions import KeyFamilyMeta -if TYPE_CHECKING: - from toad.line_blocks import LineLink - from toad.widgets.conversation import Conversation - from toad.widgets.menu import Menu - from toad.widgets.tool_call import ToolCall + +class MenuConversation: + """What a block-menu command may ask of the conversation it was opened in.""" + + @abstractmethod + def accepts_menu_owner(self, owner: Widget) -> bool: ... + + @abstractmethod + def action_copy_to_clipboard(self) -> None: ... + + @abstractmethod + def action_copy_to_prompt(self) -> None: ... + + @abstractmethod + def action_export_to_svg(self) -> None: ... + + @abstractmethod + def action_maximize_block(self) -> None: ... + + @abstractmethod + async def interrupt_shell(self) -> None: ... + + @abstractmethod + def open_line_link(self, link: LineLink) -> None: ... + + +class MenuOwner: + """The control a command was captured by, and where its effect lands.""" + + conversation: MenuConversation + block: Widget + + @abstractmethod + def activate_command(self, command: MenuCommand) -> None: ... class MenuDeclaration(KeyFamilyMeta, KeyInvocation): @@ -77,18 +107,16 @@ def bindings(self, mode: type[EditingMode]) -> tuple[ModeBinding, ...]: ).for_mode(mode) ) - def __call__(self, menu: Menu) -> None: + def __call__(self, menu: MenuOwner) -> None: menu.activate_command(self) - async def run(self, owner: Menu) -> None: + async def run(self, owner: MenuOwner) -> None: conversation = owner.conversation - if conversation.is_current_session and conversation.navigation.accepts_menu_owner( - owner.block - ): + if conversation.accepts_menu_owner(owner.block): await invoke(self.apply, conversation) @abstractmethod - def apply(self, conversation: Conversation) -> object: ... + def apply(self, conversation: MenuConversation) -> object: ... class KeyedMenuCommand(MenuCommand, declared_name=None): @@ -105,7 +133,7 @@ class CopyClipboardCommand(KeyedMenuCommand): key = "c" description = "[u]C[/]opy to clipboard" - def apply(self, conversation: Conversation) -> None: + def apply(self, conversation: MenuConversation) -> None: conversation.action_copy_to_clipboard() @@ -113,7 +141,7 @@ class CopyPromptCommand(KeyedMenuCommand): key = "p" description = "Co[u]p[/u]y to prompt" - def apply(self, conversation: Conversation) -> None: + def apply(self, conversation: MenuConversation) -> None: conversation.action_copy_to_prompt() @@ -121,7 +149,7 @@ class ExportSvgCommand(KeyedMenuCommand): key = "v" description = "Open as S[u]V[/]G" - def apply(self, conversation: Conversation) -> None: + def apply(self, conversation: MenuConversation) -> None: conversation.action_export_to_svg() @@ -129,7 +157,7 @@ class MaximizeBlockCommand(KeyedMenuCommand): key = "m" description = "[u]M[/u]aximize" - def apply(self, conversation: Conversation) -> None: + def apply(self, conversation: MenuConversation) -> None: conversation.action_maximize_block() @@ -144,7 +172,7 @@ class FocusBlockCommand(KeyedMenuCommand): key = "f" description = "Focus" - def apply(self, conversation: Conversation) -> None: + def apply(self, conversation: MenuConversation) -> None: self.block.focus() @@ -152,40 +180,16 @@ class InterruptCommand(KeyedMenuCommand): key = "i" description = "Interrupt" - async def apply(self, conversation: Conversation) -> None: + async def apply(self, conversation: MenuConversation) -> None: await conversation.interrupt_shell() -class ExpansionCommand(ExclusiveKeys, KeyedMenuCommand, declared_name=None): - """Collapse and expand alternate on one block, so they share their key.""" - - -@dataclass(frozen=True) -class CollapseCommand(ExpansionCommand): - block: ToolCall - key = "x" - description = "Collapse" - - def apply(self, conversation: Conversation) -> None: - self.block.collapse_block() - - -@dataclass(frozen=True) -class ExpandCommand(ExpansionCommand): - block: ToolCall - key = "x" - description = "Expand" - - def apply(self, conversation: Conversation) -> None: - self.block.expand_block() - - @dataclass(frozen=True) class OpenLinkCommand(MenuCommand, declared_name=None): link: LineLink - def apply(self, conversation: Conversation) -> None: - conversation.transcript.binding.history.action_open(self.link) + def apply(self, conversation: MenuConversation) -> None: + conversation.open_line_link(self.link) class OpenSenderCommand(OpenLinkCommand, KeyedMenuCommand): diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index 388ab7145..da7712683 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -7,7 +7,7 @@ from functools import partialmethod from operator import or_ from types import MappingProxyType -from typing import ClassVar, Generic, Literal, Self, TypeVar +from typing import ClassVar, Generic, Literal, Self, TypeVar, cast from agent_comms.command import Command from agent_comms.declared_family import DeclaredFamily, FamilyMeta @@ -46,6 +46,11 @@ class Action(KeyDeclaration[Context], Command, DeclaredFamily, Generic[Context], def default_title(cls) -> str: return cls.declared_name.replace("_", " ").capitalize() + @classmethod + def declarations(cls) -> Iterator[type[Self]]: + """The native registry filters by this class, so each member is one of its own.""" + return cast("Iterator[type[Self]]", super().declarations()) + @classmethod def summary(cls) -> str: """The nearest docstring a member declares; the family roots document code, not keys.""" @@ -221,7 +226,7 @@ def declared_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: *(binding for declaration in cls.declared_actions() for binding in cls.action_bindings(declaration, mode))) - def __init_subclass__(cls, actions: tuple[type[Action], ...] = (), **kwargs: object) -> None: + def __init_subclass__(cls, actions: tuple[type[Action], ...] = (), **kwargs): cls.own_actions = actions declarations = cls.declared_actions() for declaration in declarations: @@ -507,10 +512,6 @@ def present_binding(cls, declaration: type[KeyDeclaration], binding: Binding) -> }) return binding - @classmethod - def present_navigation_binding(cls, declaration: type[KeyDeclaration], binding: Binding) -> Binding: - return cls.present_binding(declaration, super().present_navigation_binding(declaration, binding)) - @classmethod def action_bindings(cls, declaration: type[Action], mode: type[EditingMode]) -> tuple[Binding, ...]: return tuple(cls.present_binding(declaration, binding) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index f771e667f..89e9404a8 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -74,7 +74,10 @@ from toad.input_history import InputHistories from toad.layout import trim_trailing_margin from toad.live_output import LiveOutput -from toad.menus import CopyClipboardCommand, CopyPromptCommand, ExportSvgCommand, MaximizeBlockCommand +from toad.line_blocks import LineLink +from toad.menus import ( + CopyClipboardCommand, CopyPromptCommand, ExportSvgCommand, MaximizeBlockCommand, MenuConversation, +) from toad.preferences import SETTINGS from toad.private_native_cursor import CursorStatus, HiddenCursorStatus from toad.resource_release import AsyncResourceRelease @@ -832,7 +835,7 @@ def watch_agent_binding(self, binding: AgentConnection) -> None: class Conversation( ConversationCommsHandlers, NativeMethodKeys, - ConversationSessionBinding, ToadNode, + ConversationSessionBinding, ToadNode, MenuConversation, actions=(ConversationAction,), ): """Holds the agent conversation (input, output, and various controls / information).""" @@ -1944,6 +1947,12 @@ async def action_select_block(self) -> None: ModeBinding("enter", action_select_block, "Select block", mode=EditingMode, show=True), ) + def accepts_menu_owner(self, owner: Widget) -> bool: + return self.is_current_session and self.navigation.accepts_menu_owner(owner) + + def open_line_link(self, link: LineLink) -> None: + self.transcript.binding.history.action_open(link) + def action_copy_to_clipboard(self) -> None: cursor = self.navigation.cursor if text := cursor.get_clipboard_text(): diff --git a/src/toad/widgets/menu.py b/src/toad/widgets/menu.py index 152ccb3ae..3c1257b47 100644 --- a/src/toad/widgets/menu.py +++ b/src/toad/widgets/menu.py @@ -2,7 +2,6 @@ from collections.abc import Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING from textual.binding import BindingsMap, KeymapClaim from textual.document._editing import EditingMode @@ -15,21 +14,18 @@ from textual.widget import Widget from textual.layouts.horizontal import HorizontalLayout -from toad.menus import MenuCommand +from toad.app_node import ToadNode +from toad.menus import MenuCommand, MenuConversation, MenuOwner from toad.keymap import KeymapCatalog from toad.widgets.selection import SelectionListItem from toad.tone_paint import UntintedTonePaint -if TYPE_CHECKING: - from toad.widgets.conversation import Conversation - - class NonSelectableLabel(Label): ALLOW_SELECT = False -class MenuOption(SelectionListItem): +class MenuOption(SelectionListItem, ToadNode): ALLOW_SELECT = False DEFAULT_LAYOUT = HorizontalLayout @@ -49,7 +45,7 @@ def refresh_key(self) -> None: self.set_class(not keys, "-no_key") -class Menu(DismissibleControl, UntintedTonePaint, ListView, can_focus=True): +class Menu(DismissibleControl, UntintedTonePaint, ListView, MenuOwner, can_focus=True): DEFAULT_CSS = """ Menu { @@ -91,7 +87,7 @@ class OptionSelected(Message): menu: Menu command: MenuCommand - def __init__(self, conversation: Conversation, owner: Widget, + def __init__(self, conversation: MenuConversation, owner: Widget, commands: Sequence[MenuCommand], *args, focus_owner: Widget, **kwargs) -> None: self.conversation = conversation self.block = owner diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index 6e8c570dc..1c5b9d705 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -14,7 +14,8 @@ from metaclass_registry.mro_dispatch import handles from toad.acp.status import ToolCallStatus from toad.line_blocks import Body, ToggleDetails, ToolOutputRole -from toad.menus import CollapseCommand, ExpandCommand, MenuCommand +from toad.block_content import CollapseCommand, ExpandCommand +from toad.menus import MenuCommand from agent_comms.ui_model.message_category import MessageCategory from toad.widgets.committed_presentation import CAPTURED_CLAIM, CapturedClaim, SnapshotPresentation, ToolOutputClaim From 0735d06bf1687aab6433173c95250089bbfe36ec Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 18:32:35 -0400 Subject: [PATCH 10/18] Pass the key mode itself to the menu's key lookup, not its metaclass settings.ui.key_mode is already a class. type() of it handed menu_keys the metaclass, so the row asked a metaclass for its binding mode. Co-Authored-By: Claude Opus 5.5 --- src/toad/widgets/menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toad/widgets/menu.py b/src/toad/widgets/menu.py index 3c1257b47..e94db1dd7 100644 --- a/src/toad/widgets/menu.py +++ b/src/toad/widgets/menu.py @@ -38,7 +38,7 @@ def compose(self) -> ComposeResult: yield NonSelectableLabel(self.command.description, id="description") def refresh_key(self) -> None: - keys = KeymapCatalog.menu_keys(self.command, type(self.app.settings.ui.key_mode), + keys = KeymapCatalog.menu_keys(self.command, self.app.settings.ui.key_mode, self.app.settings.ui.keymap) self.query_one("#key", NonSelectableLabel).update(", ".join(keys) or " ") self.set_class(bool(keys), "-has-key") From e7724949d550d82ca733bedbf31da71c3f5c7a71 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 18:41:18 -0400 Subject: [PATCH 11/18] Keep the menu target's members on the session binding, not on Conversation ConversationSessionBinding already owns is_current_session and reads the transcript. Navigation and the two menu answers move there, so Conversation shrinks instead of growing by two forwarding methods. Co-Authored-By: Claude Opus 5.5 --- src/toad/widgets/conversation.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 89e9404a8..dec335f7f 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -447,6 +447,16 @@ def is_current_session(self) -> bool: """Whether this conversation's owning session is still selected.""" return self.query_ancestor(SessionScope).is_current + @cached_property + def navigation(self) -> ContentNavigation: + return ContentNavigation(self.contents) + + def accepts_menu_owner(self, owner: Widget) -> bool: + return self.is_current_session and self.navigation.accepts_menu_owner(owner) + + def open_line_link(self, link: LineLink) -> None: + self.transcript.binding.history.action_open(link) + @property def operational_sources(self): return self.query_ancestor(SessionScope).presentation.sources @@ -1104,10 +1114,6 @@ async def interrupt_shell(self) -> None: await self.shell.interrupt() self.feedback.flash("Command interrupted", style="success") - @cached_property - def navigation(self) -> ContentNavigation: - return ContentNavigation(self.contents) - @property def cursor_block(self) -> ConversationEntry | None: return self.navigation.current @@ -1947,12 +1953,6 @@ async def action_select_block(self) -> None: ModeBinding("enter", action_select_block, "Select block", mode=EditingMode, show=True), ) - def accepts_menu_owner(self, owner: Widget) -> bool: - return self.is_current_session and self.navigation.accepts_menu_owner(owner) - - def open_line_link(self, link: LineLink) -> None: - self.transcript.binding.history.action_open(link) - def action_copy_to_clipboard(self) -> None: cursor = self.navigation.cursor if text := cursor.get_clipboard_text(): From 61614d99d2fb286ab03e03e41df209e31408c788 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 18:48:01 -0400 Subject: [PATCH 12/18] Declare contents on the session binding that now reads it Co-Authored-By: Claude Opus 5.5 --- src/toad/widgets/conversation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 2fb5462be..d839779c3 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -439,6 +439,7 @@ class ConversationSessionBinding( ): """Source-bound state and reusable rich-surface lifecycle shared by conversations.""" + contents = getters.query_one("#contents", Contents) thread_presentation: ThreadPresentation = NoThreadPresentation() """The agent thread's latest settled presentation.""" @@ -863,7 +864,6 @@ def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: MAX_LIVE_BLOCKS = 32 BINDING_GROUP_TITLE = "Conversation" - contents = getters.query_one("#contents", Contents) window = getters.query_one(Window) cursor = getters.query_one(CursorContainer) From d0469d636fd2f44ef7eaf2a98566531f1ce384ec Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 18:53:02 -0400 Subject: [PATCH 13/18] Fold the copied grid cursor walks into one move, without None branches action_cursor_up and action_cursor_down were one walk written twice. move_row owns it, matching the layout's size and the reactive highlight once, where those foreign absent values arrive. The back-to-bottom check matches the same way. Co-Authored-By: Claude Opus 5.5 --- src/toad/screens/workspace.py | 10 ++++--- src/toad/widgets/grid_select.py | 46 ++++++++++++++------------------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index 062dac459..2133fc353 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -319,10 +319,12 @@ class WorkspaceScreen(WorkspaceFrame, actions=(ViewAction,)): """The shared live workspace, with its own navigation controls.""" def back_to_bottom_available(self) -> bool: - return ( - not (self.focused is not None and self.focused.uses_screen_coordinates) - and self.back_to_bottom_window is not None - ) + match self.focused, self.back_to_bottom_window: + case Widget(uses_screen_coordinates=True), _: + return False + case _, None: + return False + return True def action_back_to_bottom(self) -> None: if (window := self.back_to_bottom_window) is not None: diff --git a/src/toad/widgets/grid_select.py b/src/toad/widgets/grid_select.py index aa9922248..04eab5e4d 100644 --- a/src/toad/widgets/grid_select.py +++ b/src/toad/widgets/grid_select.py @@ -132,37 +132,29 @@ def validate_highlighted(self, highlighted: int | None) -> int | None: return highlighted def action_cursor_up(self) -> None: - if (grid_size := self.grid_size) is None: - self.post_message(self.LeaveUp(self)) - return - if self.highlighted is None: - self.highlighted = 0 - else: - width, _height = grid_size - if self.highlighted >= width: - self.highlighted -= width - else: - self.post_message(self.LeaveUp(self)) + self.move_row(-1, self.LeaveUp) def action_cursor_down(self) -> None: - if (grid_size := self.grid_size) is None: - self.post_message(self.LeaveDown(self)) - return - - if self.highlighted is None: - self.highlighted = 0 - else: - width, height = grid_size - if self.highlighted + width < len(self.children): - self.highlighted += width - else: - self.post_message(self.LeaveDown(self)) + self.move_row(1, self.LeaveDown) + + def move_row(self, rows: Literal[-1, 1], leave: type[Message]) -> None: + """An unmeasured grid and a row past either edge both hand the cursor on.""" + match self.grid_size, self.highlighted: + case None, _: + self.post_message(leave(self)) + case _, None: + self.highlighted = 0 + case (width, _), int(cell) if 0 <= cell + rows * width < len(self.children): + self.highlighted = cell + rows * width + case _: + self.post_message(leave(self)) def cursor_horizontal(self, direction: Literal[-1, 1]) -> None: - if self.highlighted is None: - self.highlighted = 0 - else: - self.highlighted += direction + match self.highlighted: + case None: + self.highlighted = 0 + case int(cell): + self.highlighted = cell + direction def on_click(self, event: events.Click) -> None: if event.widget is None: From e2e88b48dd70f645b357072de09dcceab0cc0044 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 18:56:40 -0400 Subject: [PATCH 14/18] Type the grid cursor leave message as its constructor Co-Authored-By: Claude Opus 5.5 --- src/toad/widgets/grid_select.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toad/widgets/grid_select.py b/src/toad/widgets/grid_select.py index 04eab5e4d..fbfc898e3 100644 --- a/src/toad/widgets/grid_select.py +++ b/src/toad/widgets/grid_select.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from dataclasses import dataclass from typing import Literal @@ -137,7 +138,7 @@ def action_cursor_up(self) -> None: def action_cursor_down(self) -> None: self.move_row(1, self.LeaveDown) - def move_row(self, rows: Literal[-1, 1], leave: type[Message]) -> None: + def move_row(self, rows: Literal[-1, 1], leave: Callable[["GridSelect"], Message]) -> None: """An unmeasured grid and a row past either edge both hand the cursor on.""" match self.grid_size, self.highlighted: case None, _: From 657e57bcf7d6d932e090721b98cd5ea41a9d2c01 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 19:01:29 -0400 Subject: [PATCH 15/18] Name the grid cursor leave constructor with Self, not a string Co-Authored-By: Claude Opus 5.5 --- src/toad/widgets/grid_select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/widgets/grid_select.py b/src/toad/widgets/grid_select.py index fbfc898e3..ec90f35ec 100644 --- a/src/toad/widgets/grid_select.py +++ b/src/toad/widgets/grid_select.py @@ -1,6 +1,6 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Literal +from typing import Literal, Self from textual import containers from textual import events @@ -138,7 +138,7 @@ def action_cursor_up(self) -> None: def action_cursor_down(self) -> None: self.move_row(1, self.LeaveDown) - def move_row(self, rows: Literal[-1, 1], leave: Callable[["GridSelect"], Message]) -> None: + def move_row(self, rows: Literal[-1, 1], leave: Callable[[Self], Message]) -> None: """An unmeasured grid and a row past either edge both hand the cursor on.""" match self.grid_size, self.highlighted: case None, _: From 011fdcf1d8a7f67550eb746b5072f3b4b9641eab Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 19:07:51 -0400 Subject: [PATCH 16/18] Give the menu contract plain names and count only string-found actions MenuConversation and Conversation no longer spell block operations action_*: nothing reaches them by name. MovesCursor's methods are a contract its key family calls, so the keymap guard derives that exemption from Textual's class and its ceiling falls from 46 to 36. The message-link guard now asks the declared commands. Co-Authored-By: Claude Opus 5.5 --- src/toad/menus.py | 16 ++++++++-------- src/toad/widgets/conversation.py | 8 ++++---- .../guards/test_deleted_interface_mechanisms.py | 4 ++-- tests/guards/test_keymap_owner.py | 9 ++++++--- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/toad/menus.py b/src/toad/menus.py index 8841613d7..7516738a2 100644 --- a/src/toad/menus.py +++ b/src/toad/menus.py @@ -25,16 +25,16 @@ class MenuConversation: def accepts_menu_owner(self, owner: Widget) -> bool: ... @abstractmethod - def action_copy_to_clipboard(self) -> None: ... + def copy_block_to_clipboard(self) -> None: ... @abstractmethod - def action_copy_to_prompt(self) -> None: ... + def copy_block_to_prompt(self) -> None: ... @abstractmethod - def action_export_to_svg(self) -> None: ... + def export_block_to_svg(self) -> None: ... @abstractmethod - def action_maximize_block(self) -> None: ... + def maximize_block(self) -> None: ... @abstractmethod async def interrupt_shell(self) -> None: ... @@ -134,7 +134,7 @@ class CopyClipboardCommand(KeyedMenuCommand): description = "[u]C[/]opy to clipboard" def apply(self, conversation: MenuConversation) -> None: - conversation.action_copy_to_clipboard() + conversation.copy_block_to_clipboard() class CopyPromptCommand(KeyedMenuCommand): @@ -142,7 +142,7 @@ class CopyPromptCommand(KeyedMenuCommand): description = "Co[u]p[/u]y to prompt" def apply(self, conversation: MenuConversation) -> None: - conversation.action_copy_to_prompt() + conversation.copy_block_to_prompt() class ExportSvgCommand(KeyedMenuCommand): @@ -150,7 +150,7 @@ class ExportSvgCommand(KeyedMenuCommand): description = "Open as S[u]V[/]G" def apply(self, conversation: MenuConversation) -> None: - conversation.action_export_to_svg() + conversation.export_block_to_svg() class MaximizeBlockCommand(KeyedMenuCommand): @@ -158,7 +158,7 @@ class MaximizeBlockCommand(KeyedMenuCommand): description = "[u]M[/u]aximize" def apply(self, conversation: MenuConversation) -> None: - conversation.action_maximize_block() + conversation.maximize_block() class CopyThinkingCommand(CopyClipboardCommand): diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index d839779c3..d3b31353a 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1962,25 +1962,25 @@ async def action_select_block(self) -> None: ModeBinding("enter", action_select_block, "Select block", mode=EditingMode, show=True), ) - def action_copy_to_clipboard(self) -> None: + def copy_block_to_clipboard(self) -> None: cursor = self.navigation.cursor if text := cursor.get_clipboard_text(): self.app.copy_to_clipboard(text) self.feedback.flash("Copied to clipboard") - def action_copy_to_prompt(self) -> None: + def copy_block_to_prompt(self) -> None: cursor = self.navigation.cursor if text := cursor.get_prompt_text(): self.prompt.append(text) self.feedback.flash("Copied to prompt") self.focus_prompt() - def action_maximize_block(self) -> None: + def maximize_block(self) -> None: cursor = self.navigation.cursor if cursor.allow_maximize: cursor.maximize(self.screen) - def action_export_to_svg(self) -> None: + def export_block_to_svg(self) -> None: cursor = self.navigation.cursor if (export := cursor.export_render()) is None: return diff --git a/tests/guards/test_deleted_interface_mechanisms.py b/tests/guards/test_deleted_interface_mechanisms.py index 0776b5a1e..068864841 100644 --- a/tests/guards/test_deleted_interface_mechanisms.py +++ b/tests/guards/test_deleted_interface_mechanisms.py @@ -79,7 +79,7 @@ def test_nominal_block_interaction_caller_closure(): if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef)): assert node.name not in retired, (path,node.lineno,node.name) tree=ast.parse((ROOT/'widgets/conversation.py').read_text()) - methods={'action_copy_to_clipboard','action_copy_to_prompt','action_select_block', + methods={'copy_block_to_clipboard','copy_block_to_prompt','action_select_block', 'action_expand_block','action_collapse_block'} for method in ast.walk(tree): if isinstance(method,(ast.FunctionDef,ast.AsyncFunctionDef)) and method.name in methods: @@ -183,7 +183,7 @@ def test_a_bus_message_has_one_row_drawing_and_links_open_in_one_place(): ('action_link', 'widgets/linked_rows.py'), ('opens', 'project_link.py')): assert declared[name] == [home], (name, declared[name]) assert '_open_name' not in declared and 'open_url' not in declared - assert source.count('"block.open_sender"') == source.count('"block.open_destination"') == 1 + assert declared['OpenSenderCommand'] == declared['OpenDestinationCommand'] == ['menus.py'] # Which addresses are links is one rule; the inline parser keeps no list of its own. assert 'urlsplit' not in (ROOT / 'inline_spans.py').read_text() diff --git a/tests/guards/test_keymap_owner.py b/tests/guards/test_keymap_owner.py index 1ab79e404..9e29923ee 100644 --- a/tests/guards/test_keymap_owner.py +++ b/tests/guards/test_keymap_owner.py @@ -14,6 +14,7 @@ from textual.binding import Binding from textual.dom import DOMNode +from textual.navigation import MovesCursor from textual.screen import ModalScreen ROOT = Path(__file__).resolve().parents[2] / 'src/toad' @@ -72,14 +73,16 @@ def lines(cls, tree): class ActionMethods(KeymapMeasure): - """`def action_*`: behaviour Textual finds by string name.""" - ceiling = 46 + """`def action_*`: behaviour Textual finds by string name. A method MovesCursor declares is + a required contract its key family calls, so its implementers are not this.""" + ceiling = 36 @classmethod def lines(cls, tree): for node in ast.walk(tree): match node: - case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name) if name.startswith('action_'): + case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name) if ( + name.startswith('action_') and name not in vars(MovesCursor)): yield node.lineno From d77f0f35b187a325b7d762b7f7bfbc59f47eda14 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 19:12:06 -0400 Subject: [PATCH 17/18] Match the block export where the render may be absent Co-Authored-By: Claude Opus 5.5 --- src/toad/widgets/conversation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index d3b31353a..b3a18a99b 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1982,14 +1982,14 @@ def maximize_block(self) -> None: def export_block_to_svg(self) -> None: cursor = self.navigation.cursor - if (export := cursor.export_render()) is None: - return + match cursor.export_render(): + case None: + return + case (width, height), render: + pass import platformdirs from textual._files import generate_datetime_filename - size, render = export - width, height = size - import io import os.path From 2133e5fb50ec0508e276a4b1e15a2a2362623996 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 20:07:36 -0400 Subject: [PATCH 18/18] Restore permission j/k and delete what the menu move left dead 944056540 deleted the j and k review keys on the permissions screen as "derived from the shared key family", but that family only holds up and down. Restore the declarations and press them in a test. Delete Conversation.action_focus_block, ToolCall.action_collapse and action_expand, OpensMessageNames with LineHistory.message_row and the destination property: only menu action strings reached them, and commands now capture their operation. Delete CommsRow's CursorCommand scope and Menu and Question's signal unsubscribes, which Textual does on close. BlockCursor.export_to_svg replaces export_render's None: a no-op on the base, an override on the one cursor that draws a block. Co-Authored-By: Claude Opus 5.5 --- src/toad/block_navigation.py | 8 +++--- src/toad/message_rows.py | 7 +---- src/toad/question_actions.py | 19 ++++++++++++++ src/toad/screens/permissions.py | 4 +-- src/toad/widgets/comms_sidebar.py | 3 +-- src/toad/widgets/conversation.py | 16 ++++-------- src/toad/widgets/linked_rows.py | 15 ++--------- src/toad/widgets/menu.py | 3 --- src/toad/widgets/question.py | 1 - src/toad/widgets/tool_call.py | 6 ----- src/toad/widgets/transcript_history.py | 13 +++------- .../test_deleted_interface_mechanisms.py | 6 ++--- tests/textual/test_permission_requests.py | 26 +++++++++++++++++++ 13 files changed, 65 insertions(+), 62 deletions(-) diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 83897a400..aa53ef861 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -118,8 +118,8 @@ def show_clicked(self, window: HistoryWindow) -> None: animate=False, immediate=True, ) - def export_render(self): - return None + def export_to_svg(self, conversation: Conversation) -> None: + """Only a cursor resting on one drawn block has something to export.""" class NoBlockCursor(BlockCursor): @@ -199,12 +199,12 @@ def visible_region(self): def show_moved_to(self, window: HistoryWindow): window.scroll_to_center(self.block, immediate=True) - def export_render(self): + def export_to_svg(self, conversation: Conversation) -> None: from textual._compositor import Compositor compositor = Compositor() compositor.reflow(self.block, self.block.outer_size) - return self.block.outer_size, compositor.render_full_update() + conversation.open_render_as_svg(self.block.outer_size, compositor.render_full_update()) class FragmentBlockCursor(BlockCursor): diff --git a/src/toad/message_rows.py b/src/toad/message_rows.py index c10b3ec3f..05f60ab96 100644 --- a/src/toad/message_rows.py +++ b/src/toad/message_rows.py @@ -114,16 +114,11 @@ def sender(self) -> LineLink: """What opens the thread that sent the message.""" return self.name(self.route.sender).link - @property - def destination(self) -> LineLink: - """What opens the first thread or channel the message was sent to.""" - return self.name(self.route.targets[0]).link - @property def menu(self) -> tuple[MenuCommand, ...]: """What the cursor's menu offers on this message, wherever it is drawn.""" return (OpenSenderCommand(self.sender), - *(OpenDestinationCommand(self.destination) for _ in self.route.targets[:1])) + *(OpenDestinationCommand(self.name(target).link) for target in self.route.targets[:1])) @property def route_text(self) -> str: diff --git a/src/toad/question_actions.py b/src/toad/question_actions.py index 191f5a4c6..4e60a774b 100644 --- a/src/toad/question_actions.py +++ b/src/toad/question_actions.py @@ -96,3 +96,22 @@ class RejectAlwaysAction(SelectKindAction): title = "Reject always" keys = {EditingMode: ("R",)} group = RejectOnceAction.group + + +class PermissionNavigationAction(Action, declared_name=None): + group = Binding.Group("Navigation", compact=True) + show = True + + +class NextChangeAction(PermissionNavigationAction): + keys = {EditingMode: ("j",)} + + async def apply(self, context): + context.navigator.action_cursor_down() + + +class PreviousChangeAction(PermissionNavigationAction): + keys = {EditingMode: ("k",)} + + async def apply(self, context): + context.navigator.action_cursor_up() diff --git a/src/toad/screens/permissions.py b/src/toad/screens/permissions.py index 9031f9a15..57d8603a9 100644 --- a/src/toad/screens/permissions.py +++ b/src/toad/screens/permissions.py @@ -17,7 +17,7 @@ from toad.answer import Answer from toad.widgets.question import Question -from toad.question_actions import SelectKindAction, QuestionContext +from toad.question_actions import SelectKindAction, QuestionContext, PermissionNavigationAction from toad.app import ToadApp @@ -38,7 +38,7 @@ class ToolScroll(containers.VerticalScroll): class PermissionsScreen(QuestionContext, PriorityActions, CyclesFocus, Screen[Answer], - actions=(SelectKindAction,), + actions=(SelectKindAction, PermissionNavigationAction), scopes={PriorityActions: (SelectKindAction,)}): BINDING_GROUP_TITLE = "Permissions" AUTO_FOCUS = "Question" diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index fc5721a36..875a118d6 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -40,7 +40,6 @@ from toad.session_tracker import SidebarSelection from toad.widgets.selection import ChangingText, HoverSelection, ViewLabel from toad.application_actions import NewThreadAction -from textual.navigation import CursorCommand from toad.widget_actions import HiddenActions, OpenSelectedAction, OpensSelected from toad.widgets.session_sort import ChannelListSort, SessionSort from toad.widgets.sidebar_tree import SidebarChange, SidebarGroup, SidebarModelView, SearchFollower, TargetTree, TargetTreeMember, UnpaintedChannel @@ -167,7 +166,7 @@ def repaint_members(self, names: frozenset[str]) -> None: class CommsRow( TargetTreeMember, OpensSelected, PrimaryActivation, ContextActivation, CoreEventReceiver, HoverSelection, ViewLabel, - scopes={HiddenActions: (CursorCommand, OpenSelectedAction)}, + scopes={HiddenActions: (OpenSelectedAction,)}, ): """One interactive row: a channel or a thread.""" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index b3a18a99b..56b1b65d7 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -30,7 +30,7 @@ from textual.binding import ModeBinding from textual.content import Content from textual.css.query import NoMatches -from textual.geometry import Offset, Region +from textual.geometry import Offset, Region, Size from textual.layout import WidgetPlacement from textual.layouts.grid import GridLayout from textual.reactive import Required, var @@ -1487,10 +1487,6 @@ async def on_acp_available_commands_update( ) -> None: self.update_slash_commands() - def action_focus_block(self, block_id: str) -> None: - with suppress(NoMatches): - self.query_one(f"#{block_id}").focus() - async def set_mode(self, mode_id: str | None) -> None: if mode_id is None: return @@ -1981,12 +1977,10 @@ def maximize_block(self) -> None: cursor.maximize(self.screen) def export_block_to_svg(self) -> None: - cursor = self.navigation.cursor - match cursor.export_render(): - case None: - return - case (width, height), render: - pass + self.navigation.cursor.export_to_svg(self) + + def open_render_as_svg(self, size: Size, render: object) -> None: + width, height = size import platformdirs from textual._files import generate_datetime_filename diff --git a/src/toad/widgets/linked_rows.py b/src/toad/widgets/linked_rows.py index 5f3ff556e..e45356d54 100644 --- a/src/toad/widgets/linked_rows.py +++ b/src/toad/widgets/linked_rows.py @@ -91,22 +91,11 @@ def action_link(self, href: str) -> None: self.action_open(LineLink.from_href(href)) -class OpensMessageNames(OpensLinks): - """A block the cursor rests on while it draws a bus message: what the message's menu (`MessageRow.menu`) runs.""" +class MessageRowBlock(OpensLinks): + """A widget that is one bus message under the cursor: it offers the message's menu.""" - # The message under the cursor. message_row: MessageRow - def action_open_sender(self) -> None: - self.action_open(self.message_row.sender) - - def action_open_destination(self) -> None: - self.action_open(self.message_row.destination) - - -class MessageRowBlock(OpensMessageNames): - """A widget that is one bus message under the cursor: it offers the message's menu.""" - def get_block_menu(self) -> tuple[MenuCommand, ...]: return self.message_row.menu diff --git a/src/toad/widgets/menu.py b/src/toad/widgets/menu.py index e94db1dd7..c768430a6 100644 --- a/src/toad/widgets/menu.py +++ b/src/toad/widgets/menu.py @@ -117,9 +117,6 @@ def on_mount(self) -> None: self.screen.bindings_updated_signal.subscribe(self, self.refresh_keys) self.call_after_refresh(self.refresh_keys) - def on_unmount(self) -> None: - self.screen.bindings_updated_signal.unsubscribe(self) - def refresh_keys(self, _screen: object = None) -> None: for option in self.query(MenuOption): option.refresh_key() diff --git a/src/toad/widgets/question.py b/src/toad/widgets/question.py index 8e0fc2506..b0427eec5 100644 --- a/src/toad/widgets/question.py +++ b/src/toad/widgets/question.py @@ -199,7 +199,6 @@ def on_mount(self) -> None: self.screen.bindings_updated_signal.subscribe(self, self.refresh_option_keys) def on_unmount(self) -> None: - self.screen.bindings_updated_signal.unsubscribe(self) self.presentation.retire() def _reset_blink(self) -> None: diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index 1c5b9d705..e966e2392 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -135,12 +135,6 @@ def get_block_menu(self) -> Iterable[MenuCommand]: else: yield ExpandCommand(self) - def action_collapse(self) -> None: - self.set_expanded(False) - - def action_expand(self) -> None: - self.set_expanded(True) - def can_expand(self) -> bool: return not self.expanded diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index afbec9ccf..b803abaca 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -39,7 +39,7 @@ from toad.block_navigation import ConversationEntry, FragmentBlockCursor from toad.core.source_events import TranscriptCoverage -from toad.message_rows import MessageClock, MessageRow +from toad.message_rows import MessageClock from toad.session_scope import SessionScope from toad.transcript_filter import FilterState from toad.transcript_preparation import ( @@ -80,7 +80,7 @@ ) from toad.widgets.linked_rows import ( HorizontalRows, - OpensMessageNames, + OpensLinks, ReferenceRendering, selection_painted, ) @@ -518,7 +518,7 @@ def draws(self, message: BusMessage) -> bool: return any(fragment.draws(message) for fragment in reversed(self.fragments_in_range)) -class LineHistory(DrawsBusMessages, SourceNotifications, OpensMessageNames, TranscriptSourcePreparation, ConversationEntry, VerticalGroup): +class LineHistory(DrawsBusMessages, SourceNotifications, OpensLinks, TranscriptSourcePreparation, ConversationEntry, VerticalGroup): """History drawn as lines, a bounded range of pages at a time. It owns paging in both directions, lookahead, trimming, the jump to the newest page and the reader's position. @@ -701,13 +701,6 @@ def expand_block(self) -> None: def collapse_block(self) -> None: self._set_selected_details(False) - # The message cursor - - @property - def message_row(self) -> MessageRow: - """The bus message the cursor rests on; its menu is offered on no other fragment.""" - return self.block_cursor.current.row - @property def drawn_fragments(self) -> tuple[TranscriptFragment, ...]: """Every fragment the pages draw rows for, oldest first.""" diff --git a/tests/guards/test_deleted_interface_mechanisms.py b/tests/guards/test_deleted_interface_mechanisms.py index 068864841..63270ab73 100644 --- a/tests/guards/test_deleted_interface_mechanisms.py +++ b/tests/guards/test_deleted_interface_mechanisms.py @@ -178,11 +178,9 @@ def test_a_bus_message_has_one_row_drawing_and_links_open_in_one_place(): assert Divider.__dataclass_fields__.keys() == {'spans', 'role', 'timestamp'} # The message owns its menu and one class runs it: a view has no open actions or menu entries of its own. - for name, home in (('action_open_sender', 'widgets/linked_rows.py'), - ('action_open_destination', 'widgets/linked_rows.py'), - ('action_link', 'widgets/linked_rows.py'), ('opens', 'project_link.py')): + for name, home in (('action_link', 'widgets/linked_rows.py'), ('opens', 'project_link.py')): assert declared[name] == [home], (name, declared[name]) - assert '_open_name' not in declared and 'open_url' not in declared + assert not {'_open_name', 'open_url', 'action_open_sender', 'action_open_destination'} & declared.keys() assert declared['OpenSenderCommand'] == declared['OpenDestinationCommand'] == ['menus.py'] # Which addresses are links is one rule; the inline parser keeps no list of its own. assert 'urlsplit' not in (ROOT / 'inline_spans.py').read_text() diff --git a/tests/textual/test_permission_requests.py b/tests/textual/test_permission_requests.py index f68b1281f..28b9b0cee 100644 --- a/tests/textual/test_permission_requests.py +++ b/tests/textual/test_permission_requests.py @@ -41,6 +41,32 @@ async def flush(self): await self.actual() +async def test_j_and_k_step_through_the_changes_from_the_question(workspace): + async with workspace.app() as ui: + app, _pilot = ui.textual + agent = ui.detached_agent("session") + ui.conversation.bind_agent(agent) + await ui.step() + diffs = [{"type": "diff", "path": str(workspace.project / name), "oldText": "old", "newText": "new"} + for name in ("first.txt", "second.txt")] + call = schema.RequestPermissionRequest.model_validate(permission_call( + 1, "two-changes", kind="edit", title="Pending review", option="Allow", content=diffs)["params"]) + task = asyncio.create_task(agent.permissions.request_permission( + call.session_id, call.options, call.tool_call, call.field_meta)) + await ui.until(lambda: isinstance(app.screen, PermissionsScreen), why="the review screen") + await ui.until(lambda: app.screen.query("#navigator") and app.screen.navigator.option_count == 2, + why="both changes listed") + navigator = app.screen.navigator + assert not navigator.has_focus + await ui.press("j", "j") + await ui.until(lambda: navigator.highlighted == 1, why="j moved to the second change") + await ui.press("k") + await ui.until(lambda: navigator.highlighted == 0, why="k moved back to the first change") + await ui.press("enter") + assert (await task).outcome == schema.AllowedOutcome(outcome="selected", option_id="allow") + await agent.stop() + + @pytest.mark.parametrize("outcome", ["cancel"]) async def test_a_request_changed_while_its_screen_push_is_pending_is_cancelled_or_shown_once(workspace, outcome): async with workspace.app() as ui: