diff --git a/src/toad/block_content.py b/src/toad/block_content.py index c168baef0..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 MenuItem +from toad.menus import KeyedMenuCommand, MenuCommand, MenuConversation +from toad.widget_actions import ExclusiveKeys class BlockContent: @@ -17,7 +21,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: @@ -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/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/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/keymap.py b/src/toad/keymap.py index 2f407cb6d..ba2a278f4 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -13,13 +13,14 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import NominalSpelling, NominalText from objectstate import mark_ui_hidden_config -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 import toad from toad.key_modes import KeyMode +from toad.menus import MenuCommand from toad.setting_declarations import Effect, FamilyChoices from toad.widget_actions import ApplicationAction @@ -97,37 +98,44 @@ 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)) + 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(defaults) 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] @@ -190,11 +198,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..7516738a2 100644 --- a/src/toad/menus.py +++ b/src/toad/menus.py @@ -1,9 +1,202 @@ -from typing import NamedTuple +"""Block-menu declarations and their captured native operations.""" +from __future__ import annotations -class MenuItem(NamedTuple): - """An entry in a Menu.""" +import inspect +from abc import abstractmethod +from dataclasses import dataclass +from typing import ClassVar + +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 KeyDeclarationType, KeyInvocation +from textual.widget import Widget + +from toad.line_blocks import LineLink +from toad.widget_actions import KeyFamilyMeta + + +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 copy_block_to_clipboard(self) -> None: ... + + @abstractmethod + def copy_block_to_prompt(self) -> None: ... + + @abstractmethod + def export_block_to_svg(self) -> None: ... + + @abstractmethod + def 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): + """The declaration, not captured instance parameters, owns keyboard identity.""" 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 permits_invocation_overlap(cls, other: KeyInvocation) -> bool: + return other.overlaps_declaration(cls) + + def overlaps_declaration(cls, other: KeyDeclarationType) -> 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.""" + + description: ClassVar[str] + + @classmethod + def default_keys(cls) -> tuple[str, ...]: + return () + + 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: MenuOwner) -> None: + menu.activate_command(self) + + async def run(self, owner: MenuOwner) -> None: + conversation = owner.conversation + if conversation.accepts_menu_owner(owner.block): + await invoke(self.apply, conversation) + + @abstractmethod + def apply(self, conversation: MenuConversation) -> object: ... + + +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" + + def apply(self, conversation: MenuConversation) -> None: + conversation.copy_block_to_clipboard() + + +class CopyPromptCommand(KeyedMenuCommand): + key = "p" + description = "Co[u]p[/u]y to prompt" + + def apply(self, conversation: MenuConversation) -> None: + conversation.copy_block_to_prompt() + + +class ExportSvgCommand(KeyedMenuCommand): + key = "v" + description = "Open as S[u]V[/]G" + + def apply(self, conversation: MenuConversation) -> None: + conversation.export_block_to_svg() + + +class MaximizeBlockCommand(KeyedMenuCommand): + key = "m" + description = "[u]M[/u]aximize" + + def apply(self, conversation: MenuConversation) -> None: + conversation.maximize_block() + + +class CopyThinkingCommand(CopyClipboardCommand): + key = "y" + description = "Copy thinking" + + +@dataclass(frozen=True) +class FocusBlockCommand(KeyedMenuCommand): + block: Widget + key = "f" + description = "Focus" + + def apply(self, conversation: MenuConversation) -> None: + self.block.focus() + + +class InterruptCommand(KeyedMenuCommand): + key = "i" + description = "Interrupt" + + async def apply(self, conversation: MenuConversation) -> None: + await conversation.interrupt_shell() + + +@dataclass(frozen=True) +class OpenLinkCommand(MenuCommand, declared_name=None): + link: LineLink + + def apply(self, conversation: MenuConversation) -> None: + conversation.open_line_link(self.link) + + +class OpenSenderCommand(OpenLinkCommand, KeyedMenuCommand): + key = "s" + description = "Open [u]s[/u]ender" + + +class OpenDestinationCommand(OpenLinkCommand, KeyedMenuCommand): + key = "d" + description = "Open [u]d[/u]estination" diff --git a/src/toad/message_rows.py b/src/toad/message_rows.py index 940143c2a..05f60ab96 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, @@ -115,15 +115,10 @@ def sender(self) -> LineLink: 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[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.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 495303378..4e60a774b 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.""" 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..2133fc353 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,19 @@ 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: + 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: 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/widget_actions.py b/src/toad/widget_actions.py index 15c0bf34a..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 @@ -47,17 +47,9 @@ 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) + 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: @@ -159,7 +151,7 @@ class System: system: ClassVar[bool] = True @classmethod - def offered(cls, context) -> Iterator[Action]: + def offered(cls, context: DOMNode) -> Iterator[Self]: return iter(()) @@ -178,7 +170,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) @@ -194,7 +186,7 @@ def exclusive_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: return cls.bindings_for(mode) @classmethod - def menu(cls, app): + 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)) @@ -203,7 +195,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) -> bool: if app.current_mode != "workspace": return False return (app.screen is app.workspace_screen @@ -249,13 +241,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) @@ -303,25 +295,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: @@ -431,15 +406,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: ... @@ -508,7 +474,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) @@ -535,12 +501,12 @@ 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) }) 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/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index c06d93786..875a118d6 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -40,7 +40,7 @@ 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 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 @@ -166,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: (OpenSelectedAction,)}, ): """One interactive row: a channel or a thread.""" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 0813a3e13..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 @@ -80,7 +80,10 @@ 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.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 @@ -436,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.""" @@ -445,6 +449,21 @@ 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 + + @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 @@ -487,7 +506,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() @@ -499,7 +518,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() @@ -832,7 +851,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).""" @@ -845,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) @@ -1101,9 +1119,11 @@ def focus_terminal(self) -> None: if (terminal := self._terminal) is not None: terminal.focus() - @cached_property - def navigation(self) -> ContentNavigation: - return ContentNavigation(self.contents) + 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") @property def cursor_block(self) -> ConversationEntry | None: @@ -1360,12 +1380,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) @@ -1472,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 @@ -1698,7 +1709,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 @@ -1933,11 +1944,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) @@ -1947,33 +1958,31 @@ 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: - cursor = self.navigation.cursor - if (export := cursor.export_render()) is None: - return - import platformdirs - from textual._files import generate_datetime_filename + def export_block_to_svg(self) -> None: + self.navigation.cursor.export_to_svg(self) - size, render = export + def open_render_as_svg(self, size: Size, render: object) -> None: width, height = size + import platformdirs + from textual._files import generate_datetime_filename import io import os.path @@ -2013,7 +2022,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..e77c709d0 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -13,7 +13,7 @@ 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]: @@ -73,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 4d88c2c72..ec90f35ec 100644 --- a/src/toad/widgets/grid_select.py +++ b/src/toad/widgets/grid_select.py @@ -1,22 +1,26 @@ +from collections.abc import Callable from dataclasses import dataclass +from typing import Literal, Self 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 - - -class GridSelect(MovesCursor, Selects, containers.ItemGrid, can_focus=True): +from textual.navigation import MovesCursor +from toad.widget_actions import MovesHorizontalCursor, Selects, VisibleActions + + +class GridSelect( + VisibleActions, + 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) @@ -128,44 +132,30 @@ def validate_highlighted(self, highlighted: int | None) -> int | None: return len(self.children) - 1 return highlighted - def cursor_up(self): - 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)) - - def cursor_down(self): - 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)) - - def action_cursor_left(self): - 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 + def action_cursor_up(self) -> None: + self.move_row(-1, self.LeaveUp) + + def action_cursor_down(self) -> None: + self.move_row(1, self.LeaveDown) + + 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, _: + 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: + 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: diff --git a/src/toad/widgets/linked_rows.py b/src/toad/widgets/linked_rows.py index f63cd73e0..e45356d54 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, SelectTarget from toad.project_link import ProjectLink @@ -91,23 +91,12 @@ 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[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..c768430a6 100644 --- a/src/toad/widgets/menu.py +++ b/src/toad/widgets/menu.py @@ -1,42 +1,51 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass +from textual.binding import BindingsMap, KeymapClaim +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.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 - class NonSelectableLabel(Label): ALLOW_SELECT = False -class MenuOption(SelectionListItem): +class MenuOption(SelectionListItem, ToadNode): 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, 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): +class Menu(DismissibleControl, UntintedTonePaint, ListView, MenuOwner, can_focus=True): DEFAULT_CSS = """ Menu { @@ -76,27 +85,47 @@ 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: MenuConversation, owner: Widget, + commands: Sequence[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) + @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)))) + 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 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 +139,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/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..b0427eec5 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" @@ -198,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: @@ -256,6 +256,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/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/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index ab8cecc49..f10b2f71e 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; }" @@ -745,8 +739,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] @@ -920,10 +914,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: diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index ef3509182..e966e2392 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 MenuItem +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 @@ -128,17 +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") - - def action_collapse(self) -> None: - self.set_expanded(False) - - def action_expand(self) -> None: - self.set_expanded(True) + yield ExpandCommand(self) def can_expand(self) -> bool: return not self.expanded diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 0e5a5daab..a22238ad6 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -44,7 +44,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 MessageQueueEntry, NotificationStatus, UncheckedNotifications from toad.widgets.agent_activity import AgentActivityPresentation @@ -423,7 +423,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/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 0776b5a1e..63270ab73 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: @@ -178,12 +178,10 @@ 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 source.count('"block.open_sender"') == source.count('"block.open_destination"') == 1 + 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/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 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 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: 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" },