From 0221a9069e3abf71e57d4a71818c1b1be92ee495 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 07:17:01 -0400 Subject: [PATCH 01/14] Opt prompt editing into native modal capabilities --- pyproject.toml | 2 +- src/toad/conversation_actions.py | 11 ++++++++++- src/toad/preferences.py | 5 +++++ src/toad/prompt_actions.py | 8 ++++++-- src/toad/prompt_cursor.py | 4 ++-- src/toad/setting_effects.py | 9 +++++++++ src/toad/widgets/conversation.py | 4 ++-- src/toad/widgets/prompt.py | 31 ++++++++++++++++++++++++++++--- src/toad/widgets/prompt_popup.py | 5 +++++ src/toad/widgets/terminal.py | 5 +++++ uv.lock | 4 ++-- 11 files changed, 75 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3cb8e371e..1cc2337a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "63d92afe75f05d06310c5b92a695954891c8ed2e" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "97d73d0d5d30d9766d2c5ea9844bfa30ae96a908" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "f02c3383780acd890aab8bcd664840246419eada" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/src/toad/conversation_actions.py b/src/toad/conversation_actions.py index ba74dccd9..9f08088da 100644 --- a/src/toad/conversation_actions.py +++ b/src/toad/conversation_actions.py @@ -44,12 +44,21 @@ class CancelAction(KeyboundAction, ConversationAction): description = "Cancel" tooltip = "Cancel agent's turn" show = True + vim_profile = False def available(self, conversation): + if conversation.app.settings.ui.vim_keys != self.vim_profile: + return False return True if conversation.agent and conversation.turns.owner.can_cancel else None async def apply(self, conversation): - conversation.cancel_turn() + conversation.cancel_turn(self.key_display or self.key) + + +class VimCancelAction(CancelAction): + key = "alt+ctrl+x" + key_display = "Ctrl+Alt+X" + vim_profile = True class ExpandBlockAction(KeyboundAction, ConversationAction): diff --git a/src/toad/preferences.py b/src/toad/preferences.py index b2eb508b8..40de36100 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -77,6 +77,11 @@ class UiSettings(AnnotatedDataclassValidationMixin): theme: Annotated[str, SchemeName(), effects.theme] = "wal" """wal follows pywal's palette in true colour. ANSI themes inherit your terminal palette.""" + vim_keys: Annotated[bool, FieldLabel("Vim editing and navigation"), effects.vim_keys] = field( + default=False, metadata=saved_as("vim-keys") + ) + """Opt in to modal prompts. Escape leaves Insert; Normal Enter never sends. Ctrl+Alt+X cancels a turn.""" + prompt_message: Annotated[str, FieldLabel("Prompt message")] = "How can I help you today?" """Text shown as placeholder in prompt text area""" diff --git a/src/toad/prompt_actions.py b/src/toad/prompt_actions.py index a6149ae13..49f4cebc2 100644 --- a/src/toad/prompt_actions.py +++ b/src/toad/prompt_actions.py @@ -14,17 +14,21 @@ class SubmitAction(KeyboundAction, PromptAction): tooltip = "Send the prompt to the agent" priority = True + def available(self, editor): + return editor.submission_allowed + async def apply(self, editor): editor.schedule_submission() -class SubmitNowAction(KeyboundAction, PromptAction): +class SubmitNowAction(SubmitAction): key = "ctrl+enter,ctrl+y" + key_display = None description = "Send now" priority = True def available(self, editor): - return editor.agent_ready and editor.agent_busy and editor.queue_supported + return super().available(editor) and editor.agent_ready and editor.agent_busy and editor.queue_supported async def apply(self, editor): editor.schedule_submission(immediate=True) diff --git a/src/toad/prompt_cursor.py b/src/toad/prompt_cursor.py index c0317c05e..fa7d44941 100644 --- a/src/toad/prompt_cursor.py +++ b/src/toad/prompt_cursor.py @@ -89,7 +89,7 @@ def at_edge(cls, editor: PromptTextArea) -> bool: ... def move(cls, editor: PromptTextArea, select: bool, ordinary) -> None: from toad.core.input_events import HistoryMove - if editor.selection.is_empty and not select: + if editor.editing_mode.accepts_text and editor.selection.is_empty and not select: if cls.at_edge(editor): editor.publish_core(HistoryMove.for_mode(cls.direction, editor.shell_mode, editor.text)) return @@ -121,7 +121,7 @@ def changed(self, previous: Selection, current: Selection) -> None: from toad.widgets.prompt_popup import CompletionPopup editor = self._editor() - if editor is None: + if editor is None or not editor.editing_mode.accepts_text: return movement = CursorMovement(previous, current) prompt = editor.query_ancestor(Prompt) diff --git a/src/toad/setting_effects.py b/src/toad/setting_effects.py index 3ce4872c7..f79579d7e 100644 --- a/src/toad/setting_effects.py +++ b/src/toad/setting_effects.py @@ -55,6 +55,15 @@ def theme(app: ToadApp, value: str) -> None: app.colours.choose(value) +@Effect +def vim_keys(app: ToadApp, value: bool) -> None: + from toad.widgets.prompt import PromptTextArea + for view in app.workspace_sessions.views.values(): + for editor in view.query(PromptTextArea): + editor.apply_keyboard_profile() + app.refresh_bindings() + + @Effect def compact_input(app: ToadApp, value: bool) -> None: app.set_class(value, "-compact-input") diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 773ce9f99..a74e12ef6 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -2026,7 +2026,7 @@ def action_cursor_down(self) -> None: self.move_cursor(DownCursor()) @work - async def cancel_turn(self) -> None: + async def cancel_turn(self, shortcut: str) -> None: if monotonic() - self._last_escape_time < 3: if (agent := self.agent) is not None: self.flash("Cancelling agent turn…") @@ -2039,7 +2039,7 @@ async def cancel_turn(self) -> None: self.flash("Agent declined to cancel. Please wait.", style="error") self._last_escape_time = 0.0 else: - self.flash("Press [b]esc[/] again to cancel agent's turn") + self.flash(f"Press [b]{shortcut}[/] again to cancel agent's turn") self._last_escape_time = monotonic() def focus_prompt(self, reset_cursor: bool = True, scroll_end: bool = True) -> None: diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 710de0fea..f546a3b98 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -230,6 +230,24 @@ def highlight_shell(self, text: str) -> Content: def on_mount(self) -> None: self.highlight_cursor_line = False self.hide_suggestion_on_blur = False + self.apply_keyboard_profile() + + def apply_keyboard_profile(self) -> None: + from textual.widgets.text_area import EditingMode, VimInsert + enabled = self.app.settings.ui.vim_keys + if self.editing_mode.modal != enabled: + self.editing_mode = VimInsert() if enabled else EditingMode() + + @property + def submission_allowed(self) -> bool: + return self.editing_mode.accepts_text and not self.query_ancestor(Prompt).active_popup + + def leave_modal_editor(self) -> None: + from toad.widgets.conversation import Conversation + self.query_ancestor(Conversation).window.focus(scroll_visible=False) + + async def read_editor_clipboard(self) -> str: + return await self.app.clipboard_transport.paste(self.app) def on_key(self, event: events.Key) -> None: if self.shell_mode and event.key == "tab": @@ -257,6 +275,7 @@ def action_history_page_down(self) -> None: def _paste_clipboard_text(self, text: str) -> None: # A fresh Paste event posted directly here would bubble to App and be # forwarded back to the focused TextArea, inserting it a second time. + self.editing_mode = self.editing_mode.pasted() if result := self._replace_via_keyboard(text, *self.selection): self.move_cursor(result.end_location) @@ -281,7 +300,7 @@ async def action_paste_clipboard(self) -> None: self.notify(f"PNG attached ({len(image):,} bytes). Enter sends it with your prompt.", title="Image attachment") return - text = await self.app.clipboard_transport.paste(self.app) + text = await self.read_editor_clipboard() if text: self._paste_clipboard_text(text) else: @@ -293,7 +312,7 @@ async def action_paste_clipboard(self) -> None: def _submit(self) -> None: self._submit_pending = False immediate, self._submit_immediate = self._submit_immediate, False - if not self.has_focus: + if not self.has_focus or not self.submission_allowed: return input_text = CommandText.decode_input(self.text, self.slash_commands) local_command = not input_text.requires_agent @@ -340,6 +359,8 @@ def action_delete_left(self) -> None: def action_cursor_line_end(self, select: bool = False) -> None: """Move the cursor to the end of the line.""" + if not self.editing_mode.accepts_text: + return super().action_cursor_line_end(select) if not self._has_cursor: self.scroll_end() return @@ -509,7 +530,11 @@ def text(self) -> str: @property def supports_completion(self) -> bool: """This composer owns whether its current input mode admits completion.""" - return not self.simple_input and not self.shell_mode + return not self.simple_input and not self.shell_mode and self.prompt_text_area.editing_mode.accepts_text + + @property + def active_popup(self) -> PromptPopup | None: + return next((popup for popup in self.query(PromptPopup) if popup.is_open), None) @text.setter def text(self, text: str) -> None: diff --git a/src/toad/widgets/prompt_popup.py b/src/toad/widgets/prompt_popup.py index d0480a2b8..5fda28da5 100644 --- a/src/toad/widgets/prompt_popup.py +++ b/src/toad/widgets/prompt_popup.py @@ -41,6 +41,11 @@ def prompt(self) -> Prompt: from toad.widgets.prompt import Prompt return self.query_ancestor(Prompt) + def check_consume_key(self, key: str, character: str | None = None) -> bool: + # The popup's actual declarations outrank ancestor shortcuts, even + # before a priority Key event reaches its focused control. + return (self.is_open and key in self._bindings.key_to_bindings) or super().check_consume_key(key, character) + def admitted(self) -> bool: return True diff --git a/src/toad/widgets/terminal.py b/src/toad/widgets/terminal.py index ff4cbf295..e80fbb3f1 100644 --- a/src/toad/widgets/terminal.py +++ b/src/toad/widgets/terminal.py @@ -369,6 +369,11 @@ async def _reset_escaping(self) -> None: await self.write_process_stdin(self.state.key_escape()) self._escaping = False + def check_consume_key(self, key: str, character: str | None = None) -> bool: + # Escape belongs to the child process / double-tap blur, not to an + # ancestor's priority jump, cancellation or unmaximize binding. + return key == "escape" or super().check_consume_key(key, character) + async def on_key(self, event: events.Key): event.prevent_default() event.stop() diff --git a/uv.lock b/uv.lock index a4c8a8c72..a55972571 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=97d73d0d5d30d9766d2c5ea9844bfa30ae96a908" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=f02c3383780acd890aab8bcd664840246419eada" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=97d73d0d5d30d9766d2c5ea9844bfa30ae96a908#97d73d0d5d30d9766d2c5ea9844bfa30ae96a908" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=f02c3383780acd890aab8bcd664840246419eada#f02c3383780acd890aab8bcd664840246419eada" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 656d30ada3ac8e2ad3744806fbbc88d9dd0b43bb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 07:23:41 -0400 Subject: [PATCH 02/14] Use declared action availability for Vim navigation aliases --- src/toad/app.py | 18 ++---- src/toad/application_actions.py | 99 ++++++++++++++++++++++++++++-- src/toad/widgets/conversation.py | 2 + src/toad/widgets/history_anchor.py | 26 +++++--- 4 files changed, 115 insertions(+), 30 deletions(-) diff --git a/src/toad/app.py b/src/toad/app.py index 0dcf6ba27..62e7a360f 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -54,7 +54,8 @@ from toad.session_navigation import SessionNavigation from toad.terminal_attention import TerminalAttention from toad.textual_colours import ThemedApp -from toad.application_actions import ApplicationAction, ApplicationCommands, KeyboundAction +from toad.application_actions import ApplicationAction, ApplicationCommands +from toad.widget_actions import DeclaredWidgetActions from toad.application_lifetime import ApplicationLifetime if TYPE_CHECKING: @@ -247,14 +248,14 @@ class ApplicationNotRunningError(RuntimeError): """The application has no event loop to accept cross-thread work.""" -class ToadApp(CoreEventReceiver, WorkspaceSessionShutdown, ThemedApp, App, inherit_bindings=False): +class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown, ThemedApp, App, inherit_bindings=False): """The top level app.""" CSS_PATH = ["toad.tcss", "screens/comms.tcss"] COMMANDS = {ApplicationCommands} MODES = {"store": get_store_screen, "workspace": get_workspace_screen} BINDING_GROUP_TITLE = "System" - BINDINGS = [member.binding() for member in ApplicationAction.members_with(KeyboundAction)] + ACTIONS = ApplicationAction ALLOW_IN_MAXIMIZED_VIEW = "" project_dir = var(Path) @@ -612,17 +613,6 @@ def history_displayed(self) -> None: async def on_workspace_session_request(self, event: CoreEventMessage) -> None: await event.event.apply(self.session_navigation) - async def _dispatch_action(self, namespace, action_name: str, params) -> bool: - if namespace is self: - try: - declaration = ApplicationAction.decode(action_name) - except ValueError: - pass # Remaining names belong to Textual's own action contract. - else: - await declaration.parse(params).apply(self) - return True - return await super()._dispatch_action(namespace, action_name, params) - @on(events.TextSelected) async def on_text_selected(self) -> None: if self.settings.ui.auto_copy: diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index af1778ef7..a68ac1862 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -72,7 +72,7 @@ class ApplicationAction(NativeAction, DeclaredFamily, affix="Action"): """Application action membership is owned by its declarations.""" -class SettingsAction(ApplicationAction, KeyboundAction, PaletteAction): +class SettingsAction(KeyboundAction, ApplicationAction, PaletteAction): key = "f2,ctrl+comma" description = "Settings" tooltip = "Settings screen" @@ -118,7 +118,7 @@ async def apply(self, app: ToadApp) -> None: await SetFooterAction(not app.settings.ui.footer).apply(app) -class QuitAction(ApplicationAction, KeyboundAction, PaletteAction): +class QuitAction(KeyboundAction, ApplicationAction, PaletteAction): key = "ctrl+q" description = "Quit" tooltip = "Quit the app and return to the command prompt." @@ -132,7 +132,7 @@ async def apply(self, app: ToadApp) -> None: app.application.quit() -class HelpQuitAction(ApplicationAction, KeyboundAction): +class HelpQuitAction(KeyboundAction, ApplicationAction): key = "ctrl+c" system = True @@ -140,7 +140,7 @@ async def apply(self, app: ToadApp) -> None: app.application.confirm_quit() -class ToggleHelpPanelAction(ApplicationAction, KeyboundAction, PaletteAction): +class ToggleHelpPanelAction(KeyboundAction, ApplicationAction, PaletteAction): key = "f1" description = "Help" priority = True @@ -156,7 +156,7 @@ async def apply(self, app: ToadApp) -> None: app.action_show_help_panel() -class SessionsAction(ApplicationAction, KeyboundAction, PaletteAction): +class SessionsAction(KeyboundAction, ApplicationAction, PaletteAction): key = "ctrl+s" description = "Channels" show = True @@ -169,7 +169,7 @@ async def apply(self, app: ToadApp) -> None: app.run_worker(app.session_navigation.reveal) -class DirectMessagesAction(ApplicationAction, KeyboundAction, PaletteAction): +class DirectMessagesAction(KeyboundAction, ApplicationAction, PaletteAction): """Reach the pairwise message view without changing thread-row navigation.""" key = "ctrl+shift+d" @@ -184,6 +184,93 @@ async def apply(self, app: ToadApp) -> None: app.session_navigation.direct_messages() +class VimNavigationAction(KeyboundAction, ApplicationAction): + """Aliases act on the selected view; focus and native input retain custody.""" + + @staticmethod + def conversation(app): + from toad.widgets.conversation import Conversation + view = app.selected_session + return None if view is None else view.query_one_optional(Conversation) + + def available(self, app): + return (app.settings.ui.vim_keys and app.screen is app.workspace_screen + and self.conversation(app) is not None) + + +class VimScrollDownAction(VimNavigationAction): + key = "j" + + async def apply(self, app): + self.conversation(app).window.action_scroll_down() + + +class VimScrollUpAction(VimNavigationAction): + key = "k" + + async def apply(self, app): + self.conversation(app).window.action_scroll_up() + + +class VimScrollLeftAction(VimNavigationAction): + key = "h" + + async def apply(self, app): + self.conversation(app).window.action_scroll_left() + + +class VimScrollRightAction(VimNavigationAction): + key = "l" + + async def apply(self, app): + self.conversation(app).window.action_scroll_right() + + +class VimPageDownAction(VimNavigationAction): + key = "ctrl+d" + + async def apply(self, app): + self.conversation(app).window.action_page_down() + + +class VimPageUpAction(VimNavigationAction): + key = "ctrl+u" + + async def apply(self, app): + self.conversation(app).window.action_page_up() + + +class VimLatestAction(VimNavigationAction): + key = "G" + + async def apply(self, app): + self.conversation(app).jump_to_latest() + + +class VimFocusPromptAction(VimNavigationAction): + key = "i" + + async def apply(self, app): + from textual.widgets.text_area import VimInsert + conversation = self.conversation(app) + conversation.prompt.prompt_text_area.editing_mode = VimInsert() + conversation.focus_prompt(reset_cursor=False, scroll_end=False) + + +class VimNextTabAction(VimNavigationAction): + key = "J" + + async def apply(self, app): + app.selected_session.action_session_next() + + +class VimPreviousTabAction(VimNavigationAction): + key = "K" + + async def apply(self, app): + app.selected_session.action_session_previous() + + class ApplicationCommands(Provider): """Discovery and search derive the same capability view; no command roster.""" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index a74e12ef6..1bf23db2d 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -871,6 +871,8 @@ def _observed_activity_changed(self, event: ObservedThreadActivity.Changed) -> N @on(events.Key) async def on_key(self, event: events.Key): + if self.prompt.prompt_text_area.editing_mode.modal: + return if ( event.character is not None and event.is_printable diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index 7bbce7f42..e07319172 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -389,27 +389,33 @@ class HistoryWindow(VerticalScroll): scroll this document and must not hold its input at a reached boundary. """ + def _admit_scroll(self, distance: int, action, *args) -> None: + # Input aliases share native actions. Source admission stays with the + # existing lookahead, including when native travel is already clamped. + if not self.lookahead.request_boundary(distance): + action(*args) + + def action_scroll_up(self) -> None: + self._admit_scroll(-1, super().action_scroll_up) + + def action_scroll_down(self) -> None: + self._admit_scroll(1, super().action_scroll_down) + def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: event.prevent_default() - if not self.lookahead.request_boundary(-max(1, self.app.scroll_sensitivity_y)): - super()._on_mouse_scroll_up(event) + self._admit_scroll(-max(1, self.app.scroll_sensitivity_y), super()._on_mouse_scroll_up, event) event.stop() def action_page_up(self) -> None: - # Textual skips an action with no native travel. A lazy destination - # must admit its source range before asking that native scroll owner. - if not self.lookahead.request_boundary(-max(1, self.scrollable_content_region.height)): - super().action_page_up() + self._admit_scroll(-max(1, self.scrollable_content_region.height), super().action_page_up) def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: event.prevent_default() - if not self.lookahead.request_boundary(max(1, self.app.scroll_sensitivity_y)): - super()._on_mouse_scroll_down(event) + self._admit_scroll(max(1, self.app.scroll_sensitivity_y), super()._on_mouse_scroll_down, event) event.stop() def action_page_down(self) -> None: - if not self.lookahead.request_boundary(max(1, self.scrollable_content_region.height)): - super().action_page_down() + self._admit_scroll(max(1, self.scrollable_content_region.height), super().action_page_down) def _on_mouse_scroll_left(self, event: events.MouseScrollLeft) -> None: event.prevent_default() From 70de5188119db53d8f4faa360a18aa2c66706a67 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 08:21:21 -0400 Subject: [PATCH 03/14] Expose existing row tab and link effects to native hints --- pyproject.toml | 2 +- src/toad/app.py | 31 +++++++++++-------------------- src/toad/application_actions.py | 17 ++++++++++++++--- src/toad/clipboard.py | 8 +------- src/toad/prompt_actions.py | 2 ++ src/toad/screens/workspace.py | 1 + src/toad/widgets/comms_sidebar.py | 7 ++++++- src/toad/widgets/prompt.py | 18 +++++++++++++----- src/toad/widgets/session_tabs.py | 20 ++++++++++++++++---- uv.lock | 4 ++-- 10 files changed, 67 insertions(+), 43 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1cc2337a4..a300c0d61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "63d92afe75f05d06310c5b92a695954891c8ed2e" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "f02c3383780acd890aab8bcd664840246419eada" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "6d66b0dd4173560c2d8a38dbae35bddbdb3b997f" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/src/toad/app.py b/src/toad/app.py index 62e7a360f..b384b98df 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -21,7 +21,6 @@ from agent_comms.acp_extension import CoordinationChangedUpdate, TranscriptChangedUpdate from textual._time import after_input from textual import events, on, work -from textual.actions import ActionError, parse as parse_action from textual.app import App from textual.await_complete import AwaitComplete from textual.content import Content @@ -372,8 +371,8 @@ async def get_db(self) -> DB: def session_tracker(self) -> SessionTracker: return self._session_tracker - def copy_to_clipboard(self, text: str) -> None: - self.clipboard_transport = self.clipboard_transport.copy(self, text) + def publish_clipboard(self, text: str) -> None: + self.clipboard_transport = self.clipboard_transport.publish(self, text) def on_notify(self, event: Notify) -> None: @@ -623,27 +622,19 @@ async def on_text_selected(self) -> None: title="Automatic copy", ) - async def _broker_event(self, event_name, event, default_namespace) -> bool: - """Offer copy-path on right-click without activating a file link.""" - if event_name == "click" and isinstance(event, events.Click) and event.button == 3: + async def dispatch_style_action(self, handler, event, default_namespace) -> bool: + """Right-click adds its menu to the original decoded link action.""" + if isinstance(event, events.Click) and event.button == 3: from toad.line_blocks import LineLink - action = event.style.meta.get("@click") link = None - if isinstance(action, tuple): - match action: - case ("open", (LineLink() as target,)): - link = target - elif isinstance(action, str): - try: - parsed = parse_action(action) - except ActionError: - return await super()._broker_event(event_name, event, default_namespace) - match parsed: - case ("", "link", (str() as href,)) if href.startswith("toad-link:"): - link = LineLink.from_href(href) + match handler.action: + case ("", "open", (LineLink() as target,)): + link = target + case ("", "link", (str() as href,)) if href.startswith("toad-link:"): + link = LineLink.from_href(href) if link is not None and await link.copy_menu(default_namespace, event): return True - return await super()._broker_event(event_name, event, default_namespace) + return await super().dispatch_style_action(handler, event, default_namespace) def _set_mouse_over(self, widget, hover_widget) -> None: # Textual normally clears and reapplies the *same* :hover styles on diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index a68ac1862..9fe37776a 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -184,7 +184,12 @@ async def apply(self, app: ToadApp) -> None: app.session_navigation.direct_messages() -class VimNavigationAction(KeyboundAction, ApplicationAction): +class VimApplicationAction(KeyboundAction, ApplicationAction): + def available(self, app): + return app.settings.ui.vim_keys and app.screen is app.workspace_screen + + +class VimNavigationAction(VimApplicationAction): """Aliases act on the selected view; focus and native input retain custody.""" @staticmethod @@ -194,8 +199,7 @@ def conversation(app): return None if view is None else view.query_one_optional(Conversation) def available(self, app): - return (app.settings.ui.vim_keys and app.screen is app.workspace_screen - and self.conversation(app) is not None) + return super().available(app) and self.conversation(app) is not None class VimScrollDownAction(VimNavigationAction): @@ -257,6 +261,13 @@ async def apply(self, app): conversation.focus_prompt(reset_cursor=False, scroll_end=False) +class VimHintsAction(VimApplicationAction): + key = "f" + + async def apply(self, app): + await app.screen.action_show_hints() + + class VimNextTabAction(VimNavigationAction): key = "J" diff --git a/src/toad/clipboard.py b/src/toad/clipboard.py index 518504657..14274d246 100644 --- a/src/toad/clipboard.py +++ b/src/toad/clipboard.py @@ -21,12 +21,6 @@ def for_platform(cls) -> Clipboard: else TerminalClipboard() ) - def copy(self, app: App, text: str) -> Clipboard: - transport = self.publish(app, text) - # This is Textual's existing local-value authority, not another cache. - app._clipboard = text - return transport - @abstractmethod def publish(self, app: App, text: str) -> Clipboard: ... @@ -60,5 +54,5 @@ def read(self, app: App) -> str: class TerminalClipboard(Clipboard): def publish(self, app: App, text: str) -> Clipboard: - App.copy_to_clipboard(app, text) + App.publish_clipboard(app, text) return self diff --git a/src/toad/prompt_actions.py b/src/toad/prompt_actions.py index 49f4cebc2..88587fcfd 100644 --- a/src/toad/prompt_actions.py +++ b/src/toad/prompt_actions.py @@ -45,6 +45,8 @@ def available(self, editor): return bool(editor.text) async def apply(self, editor): + editor.editing_mode.suspend(editor) editor.clear() + editor.editing_mode.resume(editor) editor.suggestions = None editor.suggestion = "" diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index e235156b4..8a728cd7d 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -181,6 +181,7 @@ def update_node_styles(self, animate: bool = True) -> None: self._resume_style = self._style_revision() def _on_frame_published(self) -> None: + super()._on_frame_published() if self is self.app.screen: self.frame_presentation.displayed() diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index efbdc925a..891a6716b 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -32,6 +32,7 @@ from textual.dom import DOMNode from textual.reactive import reactive from textual.widgets import Static +from textual.widget import PrimaryActivation from agent_comms.mro_dispatch import handles @@ -164,7 +165,7 @@ def _display_path(path: Path) -> str: return str(path.resolve()) -class CommsRow(CoreEventReceiver, ThreadStatusRow): +class CommsRow(PrimaryActivation, CoreEventReceiver, ThreadStatusRow): """One interactive row: a channel or a thread.""" mode_name: str | None = None @@ -240,6 +241,10 @@ def action_cursor_up(self) -> None: if sidebar is not None: sidebar.action_cursor_up() + @property + def primary_activation(self): + return self.action_open_selected + def action_open_selected(self) -> None: if sidebar := self.sidebar_owner(): sidebar.remember_row(self) diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index f546a3b98..6faa265cc 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -231,6 +231,11 @@ def on_mount(self) -> None: self.highlight_cursor_line = False self.hide_suggestion_on_blur = False self.apply_keyboard_profile() + self.show_editing_mode(self.editing_mode) + + def show_editing_mode(self, mode) -> None: + if self.is_mounted: + self.query_ancestor(PromptContainer).border_subtitle = mode.label def apply_keyboard_profile(self) -> None: from textual.widgets.text_area import EditingMode, VimInsert @@ -246,8 +251,9 @@ def leave_modal_editor(self) -> None: from toad.widgets.conversation import Conversation self.query_ancestor(Conversation).window.focus(scroll_visible=False) - async def read_editor_clipboard(self) -> str: - return await self.app.clipboard_transport.paste(self.app) + async def read_editor_clipboard(self): + text = await self.app.clipboard_transport.paste(self.app) + return self.app.clipboard_content.observed(text) def on_key(self, event: events.Key) -> None: if self.shell_mode and event.key == "tab": @@ -257,7 +263,7 @@ def on_key(self, event: events.Key) -> None: self.suggestion = "" def action_newline(self) -> None: - self.insert("\n") + self._replace_via_keyboard("\n", *self.selection) def action_line_feed(self) -> None: self.action_newline() @@ -300,7 +306,7 @@ async def action_paste_clipboard(self) -> None: self.notify(f"PNG attached ({len(image):,} bytes). Enter sends it with your prompt.", title="Image attachment") return - text = await self.read_editor_clipboard() + text = (await self.read_editor_clipboard()).text if text: self._paste_clipboard_text(text) else: @@ -351,6 +357,8 @@ def action_cursor_down(self, select: bool = False): NextHistoryCursor.move(self, select, super().action_cursor_down) def action_delete_left(self) -> None: + if not self.editing_mode.accepts_text: + return super().action_delete_left() selection = self.selection if selection.start == selection.end and self.selection.end == (0, 0): self.post_message(self.CancelShell()) @@ -375,7 +383,7 @@ def action_cursor_line_end(self, select: bool = False) -> None: self.move_cursor(location, select=select) async def action_tab_complete(self) -> None: - if not self.shell_mode: + if not self.editing_mode.accepts_text or not self.shell_mode: return import shlex diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index af8287c0d..df13daea8 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -17,7 +17,7 @@ from textual.strip import Strip from textual.style import Style from ui_theme.scheme import AccentColour -from textual.widget import Widget +from textual.widget import PrimaryActivation, Widget from toad import messages from agent_comms.mro_dispatch import handles @@ -29,7 +29,7 @@ from ui_theme.effects import TAB_WIPE -class SessionLabel(widgets.Label): +class SessionLabel(PrimaryActivation, widgets.Label): ALLOW_SELECT = False def on_resize(self) -> None: @@ -37,6 +37,14 @@ def on_resize(self) -> None: # cells the underline marks. self.query_ancestor(SessionsTabs).show_selected_tab() + @property + def primary_activation(self): + return self.action_select_session + + def action_select_session(self) -> None: + if self.id is not None: + self.app.select_session(self.id) + def on_click(self, event: events.Click) -> None: if self.id is not None: if event.button == 2: @@ -47,10 +55,10 @@ def on_click(self, event: events.Click) -> None: # full-layout timer run ahead of the user's tab click. The # ordinary app mode-switch boundary already captures sidebar # navigation and queues its serialized transition. - self.app.select_session(self.id) + self.action_select_session() -class SessionTabClose(widgets.Static, can_focus=True): +class SessionTabClose(PrimaryActivation, widgets.Static, can_focus=True): """Close a tab without selecting it or deleting its saved thread.""" ALLOW_SELECT = False @@ -74,6 +82,10 @@ def __init__(self, mode_name: str) -> None: self.mode_name = mode_name self.tooltip = "Close tab (or middle-click its label)" + @property + def primary_activation(self): + return self.action_close_tab + def action_close_tab(self) -> None: self.app.session_navigation.events.publish(session_requests.SessionArchive(self.mode_name)) diff --git a/uv.lock b/uv.lock index a55972571..d53d0f9d0 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=f02c3383780acd890aab8bcd664840246419eada" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=6d66b0dd4173560c2d8a38dbae35bddbdb3b997f" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=f02c3383780acd890aab8bcd664840246419eada#f02c3383780acd890aab8bcd664840246419eada" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=6d66b0dd4173560c2d8a38dbae35bddbdb3b997f#6d66b0dd4173560c2d8a38dbae35bddbdb3b997f" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From d9be7f4c2016aafffb584f371e28fba1b89c394b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 08:34:05 -0400 Subject: [PATCH 04/14] Use native workspace mode for session navigation eligibility --- pyproject.toml | 2 +- src/toad/application_actions.py | 2 +- src/toad/widgets/prompt.py | 2 +- uv.lock | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 10938a07e..8e10dcf6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "6f0521b0ab779828fecbb9a4da3c037cf3163cc1" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "6d66b0dd4173560c2d8a38dbae35bddbdb3b997f" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index 9fe37776a..0b259d217 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -186,7 +186,7 @@ async def apply(self, app: ToadApp) -> None: class VimApplicationAction(KeyboundAction, ApplicationAction): def available(self, app): - return app.settings.ui.vim_keys and app.screen is app.workspace_screen + return app.settings.ui.vim_keys and app.current_mode == "workspace" class VimNavigationAction(VimApplicationAction): diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 6faa265cc..e8ff794f8 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -231,7 +231,7 @@ def on_mount(self) -> None: self.highlight_cursor_line = False self.hide_suggestion_on_blur = False self.apply_keyboard_profile() - self.show_editing_mode(self.editing_mode) + self.call_after_refresh(lambda: self.show_editing_mode(self.editing_mode)) def show_editing_mode(self, mode) -> None: if self.is_mounted: diff --git a/uv.lock b/uv.lock index fe961978c..8c2226be2 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=6d66b0dd4173560c2d8a38dbae35bddbdb3b997f" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=6d66b0dd4173560c2d8a38dbae35bddbdb3b997f#6d66b0dd4173560c2d8a38dbae35bddbdb3b997f" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef#5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 1806ea739b1bbf6f5263485460a15d0f6778d42e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 08:40:46 -0400 Subject: [PATCH 05/14] Pin publication-admitted native hint lifecycle --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8e10dcf6e..8bd51e3b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "6f0521b0ab779828fecbb9a4da3c037cf3163cc1" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "759e380db92475f5de973dddc095fbd68ed2ae96" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/uv.lock b/uv.lock index 8c2226be2..3c3258392 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=759e380db92475f5de973dddc095fbd68ed2ae96" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef#5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=759e380db92475f5de973dddc095fbd68ed2ae96#759e380db92475f5de973dddc095fbd68ed2ae96" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 10c70f56cef764dc09532e70bb114cb117b7a146 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 08:48:22 -0400 Subject: [PATCH 06/14] Separate native modal register paste from explicit external paste --- pyproject.toml | 2 +- src/toad/clipboard.py | 11 ++++++----- src/toad/widgets/prompt.py | 6 +----- uv.lock | 4 ++-- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8bd51e3b1..cb3c97d53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "6f0521b0ab779828fecbb9a4da3c037cf3163cc1" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "759e380db92475f5de973dddc095fbd68ed2ae96" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "2ac20ba03f0835bc44203d140dac1d65064b61ab" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/src/toad/clipboard.py b/src/toad/clipboard.py index 14274d246..87c5402b5 100644 --- a/src/toad/clipboard.py +++ b/src/toad/clipboard.py @@ -7,6 +7,7 @@ from agent_comms.declared_family import DeclaredFamily from pyperclip import PyperclipException from textual.app import App +from textual._clipboard import ClipboardContent class Clipboard(DeclaredFamily, affix="Clipboard"): @@ -24,10 +25,10 @@ def for_platform(cls) -> Clipboard: @abstractmethod def publish(self, app: App, text: str) -> Clipboard: ... - def read(self, app: App) -> str: - return app.clipboard + def read(self, app: App) -> ClipboardContent: + return app.clipboard_content - async def paste(self, app: App) -> str: + async def paste(self, app: App) -> ClipboardContent: return await asyncio.to_thread(self.read, app) @@ -45,9 +46,9 @@ def publish(self, app: App, text: str) -> Clipboard: return TerminalClipboard().publish(app, text) return self - def read(self, app: App) -> str: + def read(self, app: App) -> ClipboardContent: try: - return self.native_paste() + return ClipboardContent(self.native_paste()) except OSError, PyperclipException: return super().read(app) diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index e8ff794f8..818d8c651 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -251,10 +251,6 @@ def leave_modal_editor(self) -> None: from toad.widgets.conversation import Conversation self.query_ancestor(Conversation).window.focus(scroll_visible=False) - async def read_editor_clipboard(self): - text = await self.app.clipboard_transport.paste(self.app) - return self.app.clipboard_content.observed(text) - def on_key(self, event: events.Key) -> None: if self.shell_mode and event.key == "tab": event.prevent_default() @@ -306,7 +302,7 @@ async def action_paste_clipboard(self) -> None: self.notify(f"PNG attached ({len(image):,} bytes). Enter sends it with your prompt.", title="Image attachment") return - text = (await self.read_editor_clipboard()).text + text = (await self.app.clipboard_transport.paste(self.app)).text if text: self._paste_clipboard_text(text) else: diff --git a/uv.lock b/uv.lock index 3c3258392..088e997d1 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=759e380db92475f5de973dddc095fbd68ed2ae96" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=2ac20ba03f0835bc44203d140dac1d65064b61ab" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=759e380db92475f5de973dddc095fbd68ed2ae96#759e380db92475f5de973dddc095fbd68ed2ae96" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=2ac20ba03f0835bc44203d140dac1d65064b61ab#2ac20ba03f0835bc44203d140dac1d65064b61ab" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 5a35c6b3ff1d603fc98b586880f5368822c514a0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 09:13:35 -0400 Subject: [PATCH 07/14] Pin exact displayed-label completion for native hints --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0257caab9..138ae9b33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "4dca00f1d309960e489bb3f58710fd3a9360703a" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "2ac20ba03f0835bc44203d140dac1d65064b61ab" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "fdf284d31acf9f1ce9bc2dbdea556e987336c68e" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/uv.lock b/uv.lock index ef47d9ef5..f7b9af4f7 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=2ac20ba03f0835bc44203d140dac1d65064b61ab" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=fdf284d31acf9f1ce9bc2dbdea556e987336c68e" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=2ac20ba03f0835bc44203d140dac1d65064b61ab#2ac20ba03f0835bc44203d140dac1d65064b61ab" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=fdf284d31acf9f1ce9bc2dbdea556e987336c68e#fdf284d31acf9f1ce9bc2dbdea556e987336c68e" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 1733c73c517dc34874f5a9da944487577678f991 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 09:36:35 -0400 Subject: [PATCH 08/14] Pin shared native visual ranges and modal command lifecycle --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 138ae9b33..972cbd72c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "4dca00f1d309960e489bb3f58710fd3a9360703a" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "fdf284d31acf9f1ce9bc2dbdea556e987336c68e" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/uv.lock b/uv.lock index f7b9af4f7..cd5e5a2c4 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=fdf284d31acf9f1ce9bc2dbdea556e987336c68e" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=fdf284d31acf9f1ce9bc2dbdea556e987336c68e#fdf284d31acf9f1ce9bc2dbdea556e987336c68e" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8#bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 287a22c2346ef351c12ff9db175c17875410d8fd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 10:08:04 -0400 Subject: [PATCH 09/14] Expose existing primary effects across visible custom controls --- src/toad/widgets/comms_menu.py | 12 ++++++++-- src/toad/widgets/comms_sidebar.py | 11 +++++++-- src/toad/widgets/goal_bar.py | 7 +++++- src/toad/widgets/input_delivery.py | 7 +++++- src/toad/widgets/model_switcher.py | 7 +++++- src/toad/widgets/project_panel.py | 7 +++++- src/toad/widgets/prompt.py | 26 +++++++++++++++------ src/toad/widgets/question.py | 11 +++++++-- src/toad/widgets/recovery_view.py | 7 +++++- src/toad/widgets/session_sort.py | 7 +++++- src/toad/widgets/side_bar.py | 31 +++++++++++++++++++++----- src/toad/widgets/sidebar_tree.py | 7 +++++- src/toad/widgets/transcript_history.py | 14 +++++++++--- 13 files changed, 125 insertions(+), 29 deletions(-) diff --git a/src/toad/widgets/comms_menu.py b/src/toad/widgets/comms_menu.py index fdcf41f13..260af8aa8 100644 --- a/src/toad/widgets/comms_menu.py +++ b/src/toad/widgets/comms_menu.py @@ -13,10 +13,11 @@ from textual.message import Message from textual.screen import ModalScreen from textual.widgets import Static +from textual.widget import PrimaryActivation from toad.widgets.selection import FocusSelection -class ContextMenuItem(FocusSelection): +class ContextMenuItem(PrimaryActivation, FocusSelection): """A menu item with desktop-style press and release behavior.""" class Selected(Message): @@ -42,7 +43,14 @@ def on_mouse_up(self, event: events.MouseUp) -> None: def on_click(self, event: events.Click) -> None: if event.button == 1: event.stop() - self.post_message(self.Selected(self)) + self.action_select() + + @property + def primary_activation(self): + return self.action_select + + def action_select(self) -> None: + self.post_message(self.Selected(self)) def on_leave(self) -> None: self.remove_class("-pressed") diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index 891a6716b..c31756fac 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -57,13 +57,20 @@ class ChannelDisclosure(SidebarDisclosure): pass -class ChannelUnread(Static): +class ChannelUnread(PrimaryActivation, Static): DEFAULT_CSS = "ChannelUnread { width: auto; height: 1; color: $accent; pointer: pointer; }" def on_click(self, event) -> None: if event.button == 1: event.stop() - self.query_ancestor(ChannelGroup).row.action_open_selected() + self.action_open_selected() + + @property + def primary_activation(self): + return self.action_open_selected + + def action_open_selected(self): + self.query_ancestor(ChannelGroup).row.action_open_selected() class ChannelGroup(SidebarGroup): diff --git a/src/toad/widgets/goal_bar.py b/src/toad/widgets/goal_bar.py index 2b4465c0c..8159f00a7 100644 --- a/src/toad/widgets/goal_bar.py +++ b/src/toad/widgets/goal_bar.py @@ -12,6 +12,7 @@ from textual.strip import Strip from ui_theme.effects import STANDBY_PULSE from textual.widgets import Static +from textual.widget import PrimaryActivation from toad.goal_interaction import GoalInteraction from toad.core.input_events import GoalControlActivated @@ -20,13 +21,17 @@ from toad.textual_colours import Animated -class GoalControl(CoreEventReceiver, Static, can_focus=True): +class GoalControl(PrimaryActivation, CoreEventReceiver, Static, can_focus=True): BINDINGS: ClassVar[list[BindingType]] = [("enter,space", "activate", "Select")] DEFAULT_CSS = """ GoalControl { width: auto; height: 1; margin-right: 2; color: $text-secondary; pointer: pointer; } GoalControl:hover, GoalControl:focus { text-style: underline; } """ + @property + def primary_activation(self): + return self.action_activate + def action_activate(self): if not self.disabled: self.publish_core(GoalControlActivated(GoalInteraction.decode(self.id.removeprefix("goal-")))) diff --git a/src/toad/widgets/input_delivery.py b/src/toad/widgets/input_delivery.py index 2343786de..674dd4873 100644 --- a/src/toad/widgets/input_delivery.py +++ b/src/toad/widgets/input_delivery.py @@ -11,12 +11,13 @@ from textual.reactive import var from textual.screen import ModalScreen from textual.widgets import Button, Static +from textual.widget import PrimaryActivation from agent_comms.ui_model.delivery import InputDelivery from toad.project_link import AcpLogLink -class DetailAction(Static, can_focus=True): +class DetailAction(PrimaryActivation, Static, can_focus=True): """An inline detail action owns keyboard, pointer and activation behavior.""" class Activated(Message): @@ -39,6 +40,10 @@ def control(self) -> DetailAction: DetailAction:hover, DetailAction:focus { text-style: bold reverse; } """ + @property + def primary_activation(self): + return self.action_activate + def action_activate(self) -> None: if not self.disabled: self.post_message(self.Activated(self)) diff --git a/src/toad/widgets/model_switcher.py b/src/toad/widgets/model_switcher.py index bf752ba15..90123ff43 100644 --- a/src/toad/widgets/model_switcher.py +++ b/src/toad/widgets/model_switcher.py @@ -12,6 +12,7 @@ from textual.content import Content from textual.reactive import var from textual.widgets import Input, OptionList, Static +from textual.widget import PrimaryActivation from textual.widgets.option_list import Option from toad import messages @@ -23,12 +24,16 @@ from toad.widgets.prompt import Prompt -class ConnectProvider(CoreEventReceiver, Static, can_focus=True): +class ConnectProvider(PrimaryActivation, CoreEventReceiver, Static, can_focus=True): BINDINGS = [("enter,space", "connect", "Connect provider")] def __init__(self): super().__init__("Connect a provider…", markup=False) + @property + def primary_activation(self): + return self.action_connect + def action_connect(self): self.query_ancestor(ModelSwitcher).action_dismiss() self.publish_core(input_events.ProviderLogin()) diff --git a/src/toad/widgets/project_panel.py b/src/toad/widgets/project_panel.py index 1dfdac608..10630efbf 100644 --- a/src/toad/widgets/project_panel.py +++ b/src/toad/widgets/project_panel.py @@ -10,13 +10,14 @@ from textual.containers import Vertical, VerticalScroll from textual.message import Message from textual.widgets import Static +from textual.widget import PrimaryActivation from toad.widgets.project_directory_tree import ProjectDirectoryTree from toad.widgets.project_tree_intent import ProjectTreeIntent from toad.widgets.worker_static import WorkerStatic -class ProjectSearchButton(Static, can_focus=True): +class ProjectSearchButton(PrimaryActivation, Static, can_focus=True): DEFAULT_CSS = """ ProjectSearchButton { height: 1; @@ -38,6 +39,10 @@ class Requested(Message): def __init__(self) -> None: super().__init__("⌕ Search files") + @property + def primary_activation(self): + return self.action_search + def action_search(self) -> None: self.post_message(self.Requested()) diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 818d8c651..f95595ba1 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -20,7 +20,7 @@ from textual.message import Message from textual.widgets import OptionList, TextArea, Label from textual import containers -from textual.widget import Widget +from textual.widget import PrimaryActivation, Widget from textual.widgets.option_list import Option from textual.widgets.text_area import Selection from textual import events @@ -60,13 +60,19 @@ def action_dismiss(self): self.blur() -class AgentInfo(Label): - pass +class AgentInfo(PrimaryActivation, Label): + @property + def primary_activation(self): + return self.query_ancestor(Prompt).on_agent_info_click -class SendNow(Label, can_focus=True): +class SendNow(PrimaryActivation, Label, can_focus=True): BINDINGS = [("enter,space", "send_now", "Send now")] + @property + def primary_activation(self): + return self.action_send_now + def action_send_now(self): self.post_message(messages.SendPromptNow()) @@ -75,8 +81,10 @@ def on_click(self, event: events.Click): self.action_send_now() -class ModeInfo(Label): - pass +class ModeInfo(PrimaryActivation, Label): + @property + def primary_activation(self): + return self.query_ancestor(Prompt).on_mode_info_click class StatusLine(Label): @@ -88,11 +96,15 @@ def watch_status(self, status: str | Content | RichText) -> None: self.tooltip = status -class QueueSummary(Label, can_focus=True): +class QueueSummary(PrimaryActivation, Label, can_focus=True): """Shows the remote queue; activation reports available control support.""" BINDINGS = [("enter,space", "edit_queue", "Queue controls")] + @property + def primary_activation(self): + return self.action_edit_queue + def action_edit_queue(self) -> None: from toad.widgets.conversation import Conversation diff --git a/src/toad/widgets/question.py b/src/toad/widgets/question.py index 01622bc76..5f206642e 100644 --- a/src/toad/widgets/question.py +++ b/src/toad/widgets/question.py @@ -10,7 +10,7 @@ from textual.content import Content from textual.reactive import var, reactive from textual.message import Message -from textual.widget import Widget +from textual.widget import PrimaryActivation, Widget from textual import widgets @@ -36,7 +36,7 @@ class NonSelectableLabel(widgets.Label): ALLOW_SELECT = False -class Option(containers.HorizontalGroup): +class Option(PrimaryActivation, containers.HorizontalGroup): ALLOW_SELECT = False DEFAULT_CSS = """ Option { @@ -106,6 +106,13 @@ def compose(self) -> ComposeResult: def on_click(self, event: events.Click) -> None: event.stop() + self.action_select() + + @property + def primary_activation(self): + return self.action_select + + def action_select(self) -> None: self.post_message(self.Selected(self.index)) diff --git a/src/toad/widgets/recovery_view.py b/src/toad/widgets/recovery_view.py index 908f46e44..33d85a57b 100644 --- a/src/toad/widgets/recovery_view.py +++ b/src/toad/widgets/recovery_view.py @@ -13,6 +13,7 @@ from textual import events from textual.binding import Binding from textual.widgets import Static +from textual.widget import PrimaryActivation from textual.worker import Worker from toad.widgets.side_bar import SideBarCollapsible @@ -21,7 +22,7 @@ _UNAVAILABLE = "Recovery unavailable\n\n" -class RecoveryView(CoreEventReceiver, Static): +class RecoveryView(PrimaryActivation, CoreEventReceiver, Static): """A disposable presentation of a validated gateway DTO, never an owner.""" DEFAULT_CSS = "RecoveryView { height: 3; text-wrap: nowrap; text-overflow: ellipsis; pointer: pointer; }" @@ -85,6 +86,10 @@ def set_identity(self, thread: str, wire_root: str | Path | None) -> None: self.update(_UNAVAILABLE, layout=False) self._after_paint_read() + @property + def primary_activation(self): + return self.action_refresh + def action_refresh(self) -> None: """Only a visible local UI action retries an unavailable gateway.""" self._after_paint_read() diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index 3890a9f98..7c0732271 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -17,6 +17,7 @@ from textual.containers import VerticalScroll from textual.screen import ModalScreen from textual.widgets import Static +from textual.widget import PrimaryActivation from toad.forms.declared_views import DeclaredField from toad.forms.field_path import FieldPath @@ -90,7 +91,7 @@ def action_cancel(self): self.dismiss(None) -class SortControl[Order: DisplayOrder](Static, can_focus=True): +class SortControl[Order: DisplayOrder](PrimaryActivation, Static, can_focus=True): BINDINGS = [("enter,space", "choose_sort", "Sort")] DEFAULT_CSS = """ SortControl { @@ -136,6 +137,10 @@ def _update_label(self) -> None: if self.tooltip != tooltip: self.tooltip = tooltip + @property + def primary_activation(self): + return self.action_choose_sort + def action_choose_sort(self): form = self.form if form is None: diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 1ac1551f8..500b714d7 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -15,7 +15,7 @@ from textual.geometry import Offset from textual.message import Message from textual.reactive import reactive -from textual.widget import Widget +from textual.widget import PrimaryActivation, Widget from toad.session_tracker import SidebarState from toad.sidebar_layout import SidebarPlacementAction @@ -113,7 +113,7 @@ def compose(self) -> ComposeResult: yield from self._contents_list -class SideBarToggle(widgets.Static): +class SideBarToggle(PrimaryActivation, widgets.Static): """Full-height collapse target beside the conversation-facing resize edge.""" ALLOW_SELECT = False @@ -162,6 +162,13 @@ def set_collapsed(self, collapsed: bool) -> None: if self.tooltip != tooltip: self.tooltip = tooltip + @property + def primary_activation(self): + return self.action_pointer_toggle + + def action_pointer_toggle(self) -> None: + self.query_ancestor(SideBar).toggle(focus=False) + def action_sidebar_toggle(self) -> None: self.post_message(self.Pressed()) @@ -174,10 +181,10 @@ def focus_on_click(self) -> bool: def on_click(self, event: Click) -> None: if event.button == 1: event.stop() - self.query_ancestor(SideBar).toggle(focus=False) + self.action_pointer_toggle() -class TabHistoryButton(widgets.Static, can_focus=True): +class TabHistoryButton(PrimaryActivation, widgets.Static, can_focus=True): """One compact, keyboard-accessible tab-history control.""" BINDINGS: ClassVar[list[BindingType]] = [ @@ -211,6 +218,10 @@ def __init__(self, direction: int) -> None: self.tooltip = ("Back through visited tabs" if direction == -1 else "Forward through visited tabs") + @property + def primary_activation(self): + return self.action_navigate + def action_navigate(self) -> None: if not self.has_class("-unavailable"): cast("ToadApp", self.app).tab_order.navigate(self.direction) @@ -221,7 +232,7 @@ def on_click(self, event: Click) -> None: self.action_navigate() -class MainMenuButton(widgets.Static, can_focus=True): +class MainMenuButton(PrimaryActivation, widgets.Static, can_focus=True): """Open Toad's existing anchored menu beside the tab-history controls.""" BINDINGS: ClassVar[list[BindingType]] = [ @@ -251,6 +262,10 @@ def __init__(self) -> None: super().__init__("☰", id="main-menu") self.tooltip = "Main menu: export wire or import thread" + @property + def primary_activation(self): + return self.action_open_menu + def action_open_menu(self) -> None: self.post_message(self.Pressed()) @@ -426,7 +441,7 @@ def on_mouse_release(self) -> None: self._dragging = False -class SidebarAction(widgets.Static, can_focus=True): +class SidebarAction(PrimaryActivation, widgets.Static, can_focus=True): """One spatial arrow or float/push action.""" BINDINGS: ClassVar[list[BindingType]] = [ @@ -451,6 +466,10 @@ def __init__(self, action: "SidebarPlacementAction") -> None: self.action = action self.set_class(action.directional, "-direction") + @property + def primary_activation(self): + return self.action_activate + def action_activate(self) -> None: self.post_message(self.Pressed(self.action)) diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index de0ed8099..180e04316 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -8,17 +8,22 @@ from textual.reactive import reactive from textual.containers import Vertical, VerticalGroup, VerticalScroll from textual.widgets import Static +from textual.widget import PrimaryActivation from toad.widgets.sidebar_viewport import SidebarHeader -class SidebarDisclosure(Static, can_focus=True): +class SidebarDisclosure(PrimaryActivation, Static, can_focus=True): BINDINGS = [Binding("enter,space", "toggle", "Expand group", show=False)] DEFAULT_CSS = "SidebarDisclosure { width: 2; height: 1; pointer: pointer; }" def render(self): return "▾" if self.query_ancestor(SidebarGroup).expanded else "▸" + @property + def primary_activation(self): + return self.action_toggle + def action_toggle(self) -> None: self.query_ancestor(SidebarGroup).toggle_members() diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 2f0b145de..7a153f383 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -29,7 +29,7 @@ from textual.geometry import Region from textual.message import Message from textual.selection import Selection -from textual.widget import Widget +from textual.widget import PrimaryActivation, Widget from textual.walk import walk_depth_first from textual.strip import Strip from textual.widgets import Static @@ -73,13 +73,17 @@ class _DrawingStopped(Exception): """Unwind an anchor transaction whose source owner no longer publishes.""" -class HistoryEdge(Static, can_focus=True): +class HistoryEdge(PrimaryActivation, Static, can_focus=True): DEFAULT_CSS = "HistoryEdge { height: 1; color: $text-muted; pointer: pointer; }" BINDINGS = [("enter,space", "earlier", "Earlier history")] class Requested(Message): pass + @property + def primary_activation(self): + return self.action_earlier + def action_earlier(self) -> None: self.post_message(self.Requested()) @@ -88,13 +92,17 @@ def on_click(self, event) -> None: event.stop() self.action_earlier() -class JumpToLatest(Static, can_focus=True): +class JumpToLatest(PrimaryActivation, Static, can_focus=True): BINDINGS = [("enter,space", "jump", "Jump to latest")] DEFAULT_CSS = "JumpToLatest { height: 1; color: $text-secondary; pointer: pointer; }" class Requested(Message): pass + @property + def primary_activation(self): + return self.action_jump + def action_jump(self) -> None: self.post_message(self.Requested()) From 1f38691063404e24527f8c286e2488d2a68b8261 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 10:23:07 -0400 Subject: [PATCH 10/14] Share opt-in modal hint bindings through the native screen owner --- src/toad/application_actions.py | 12 ++++++++++-- src/toad/screens/action_modal.py | 2 +- src/toad/screens/agent_modal.py | 2 +- src/toad/screens/command_edit_modal.py | 2 +- src/toad/screens/goal_details.py | 2 +- src/toad/screens/goal_edit.py | 2 +- src/toad/screens/historical_sessions.py | 2 +- src/toad/screens/mcp_decision.py | 2 +- src/toad/screens/mcp_inventory.py | 2 +- src/toad/screens/modal.py | 9 +++++++++ src/toad/screens/new_session.py | 2 +- src/toad/screens/session_resume_modal.py | 2 +- src/toad/screens/settings.py | 2 +- src/toad/widgets/comms_command_dialog.py | 2 +- src/toad/widgets/comms_menu.py | 2 +- src/toad/widgets/comms_transfer.py | 2 +- src/toad/widgets/input_delivery.py | 2 +- src/toad/widgets/session_sort.py | 2 +- src/toad/widgets/side_bar.py | 4 ++-- 19 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 src/toad/screens/modal.py diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index 0b259d217..27b66115e 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -28,7 +28,7 @@ class KeyboundAction: @classmethod def binding(cls) -> Binding: - return Binding(cls.key, FieldCodec.encode(cls), cls.description, tooltip=cls.tooltip, + return Binding(cls.key, cls.binding_name(), cls.description, tooltip=cls.tooltip, show=cls.show, priority=cls.priority, system=cls.system, group=cls.group, key_display=cls.key_display) @@ -51,6 +51,12 @@ def label(self, app: ToadApp) -> str: ... class NativeAction(Command, Generic[Context]): """Only the Textual boundary decodes an external action name.""" + binding_prefix: ClassVar[str] = "" + + @classmethod + def binding_name(cls) -> str: + return cls.binding_prefix + FieldCodec.encode(cls) + @classmethod def parse(cls, parameters: tuple[object, ...]): if parameters: @@ -69,7 +75,9 @@ async def apply(self, context: Context) -> None: ... class ApplicationAction(NativeAction, DeclaredFamily, affix="Action"): - """Application action membership is owned by its declarations.""" + """Application action membership and native namespace belong here.""" + + binding_prefix = "app." class SettingsAction(KeyboundAction, ApplicationAction, PaletteAction): diff --git a/src/toad/screens/action_modal.py b/src/toad/screens/action_modal.py index 10df8cb4e..14ef70a51 100644 --- a/src/toad/screens/action_modal.py +++ b/src/toad/screens/action_modal.py @@ -5,7 +5,7 @@ from textual import containers from textual import getters from textual.content import Content -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual import widgets from textual.widget import Widget diff --git a/src/toad/screens/agent_modal.py b/src/toad/screens/agent_modal.py index d7d6d9356..9297684aa 100644 --- a/src/toad/screens/agent_modal.py +++ b/src/toad/screens/agent_modal.py @@ -6,7 +6,7 @@ from textual import work from textual.content import Content -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual import containers from textual import widgets diff --git a/src/toad/screens/command_edit_modal.py b/src/toad/screens/command_edit_modal.py index 22d01707b..fa0bbb3ca 100644 --- a/src/toad/screens/command_edit_modal.py +++ b/src/toad/screens/command_edit_modal.py @@ -1,6 +1,6 @@ from textual import on, work from textual.app import ComposeResult -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual import widgets from textual import containers from textual import getters diff --git a/src/toad/screens/goal_details.py b/src/toad/screens/goal_details.py index 0e5ea4a81..6f67d9ac5 100644 --- a/src/toad/screens/goal_details.py +++ b/src/toad/screens/goal_details.py @@ -9,7 +9,7 @@ from textual import on from textual.app import ComposeResult from textual.containers import Vertical, VerticalScroll -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Button, Static from toad.core.input_events import SelectTarget diff --git a/src/toad/screens/goal_edit.py b/src/toad/screens/goal_edit.py index 1d7586cf8..07851aa23 100644 --- a/src/toad/screens/goal_edit.py +++ b/src/toad/screens/goal_edit.py @@ -7,7 +7,7 @@ from textual import on from textual.app import ComposeResult from textual.containers import HorizontalGroup, Vertical -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Button, Static from toad.core.input_events import UserInputSubmitted diff --git a/src/toad/screens/historical_sessions.py b/src/toad/screens/historical_sessions.py index 3cae7056a..cbf953e87 100644 --- a/src/toad/screens/historical_sessions.py +++ b/src/toad/screens/historical_sessions.py @@ -17,7 +17,7 @@ from textual import on from textual.app import ComposeResult from textual.containers import Vertical, VerticalGroup -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Select, Static from toad.screens.workspace import WorkspaceScreen diff --git a/src/toad/screens/mcp_decision.py b/src/toad/screens/mcp_decision.py index 1f5da20df..ba6d47500 100644 --- a/src/toad/screens/mcp_decision.py +++ b/src/toad/screens/mcp_decision.py @@ -7,7 +7,7 @@ from textual import containers, events, on from textual.app import ComposeResult from textual.binding import Binding -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Button, Static from toad.mcp_decision import LocalDecisionPTY diff --git a/src/toad/screens/mcp_inventory.py b/src/toad/screens/mcp_inventory.py index cf7504929..2ad57a06c 100644 --- a/src/toad/screens/mcp_inventory.py +++ b/src/toad/screens/mcp_inventory.py @@ -8,7 +8,7 @@ from textual import containers, on from textual.app import ComposeResult -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Button, OptionList, Static from textual.widgets.option_list import Option diff --git a/src/toad/screens/modal.py b/src/toad/screens/modal.py new file mode 100644 index 000000000..4cb391ebf --- /dev/null +++ b/src/toad/screens/modal.py @@ -0,0 +1,9 @@ +"""Toad modal screens share opt-in hints without bypassing native input custody.""" + +from textual.screen import ModalScreen as NativeModalScreen + +from toad.application_actions import VimHintsAction + + +class ModalScreen[Result](NativeModalScreen[Result]): + BINDINGS = list(VimHintsAction.bindings()) diff --git a/src/toad/screens/new_session.py b/src/toad/screens/new_session.py index 3b93d1e45..dc4a80cc7 100644 --- a/src/toad/screens/new_session.py +++ b/src/toad/screens/new_session.py @@ -3,7 +3,7 @@ from textual import containers, on, widgets from textual.app import ComposeResult -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from toad.widgets.directory_input import DirectoryInput diff --git a/src/toad/screens/session_resume_modal.py b/src/toad/screens/session_resume_modal.py index 0ade8021e..8ce6b4a3a 100644 --- a/src/toad/screens/session_resume_modal.py +++ b/src/toad/screens/session_resume_modal.py @@ -2,7 +2,7 @@ from textual import containers, getters, on, widgets from textual.app import ComposeResult -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from toad.db import DB, Session diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index 657ab0193..87c4572d3 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -7,7 +7,7 @@ from textual import containers, events, getters from textual.app import ComposeResult from textual.binding import CallBinding -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Footer, Input from toad.app import ToadApp diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 3ca612c59..6a1e79bd9 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -5,7 +5,7 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Button, Checkbox, Input, Select, Static, TextArea diff --git a/src/toad/widgets/comms_menu.py b/src/toad/widgets/comms_menu.py index 260af8aa8..e4f5bd39b 100644 --- a/src/toad/widgets/comms_menu.py +++ b/src/toad/widgets/comms_menu.py @@ -11,7 +11,7 @@ from textual.dom import DOMNode from textual.geometry import Offset, clamp from textual.message import Message -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Static from textual.widget import PrimaryActivation from toad.widgets.selection import FocusSelection diff --git a/src/toad/widgets/comms_transfer.py b/src/toad/widgets/comms_transfer.py index d07c6d72d..e2b8b746d 100644 --- a/src/toad/widgets/comms_transfer.py +++ b/src/toad/widgets/comms_transfer.py @@ -28,7 +28,7 @@ from textual import on from textual.app import ComposeResult from textual.containers import Vertical -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Button, Input, Select, Static diff --git a/src/toad/widgets/input_delivery.py b/src/toad/widgets/input_delivery.py index 674dd4873..0eb3a7360 100644 --- a/src/toad/widgets/input_delivery.py +++ b/src/toad/widgets/input_delivery.py @@ -9,7 +9,7 @@ from textual.containers import HorizontalGroup, Vertical, VerticalScroll from textual.message import Message from textual.reactive import var -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Button, Static from textual.widget import PrimaryActivation diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index 7c0732271..c0ef7cf62 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -15,7 +15,7 @@ from python_introspect import FieldLabel from textual import events from textual.containers import VerticalScroll -from textual.screen import ModalScreen +from toad.screens.modal import ModalScreen from textual.widgets import Static from textual.widget import PrimaryActivation diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 500b714d7..1f67e26f2 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -223,8 +223,7 @@ def primary_activation(self): return self.action_navigate def action_navigate(self) -> None: - if not self.has_class("-unavailable"): - cast("ToadApp", self.app).tab_order.navigate(self.direction) + cast("ToadApp", self.app).tab_order.navigate(self.direction) def on_click(self, event: Click) -> None: if event.button == 1: @@ -322,6 +321,7 @@ def _sync(self) -> None: app = cast("ToadApp", self.app) for button in self.query(TabHistoryButton): unavailable = app.tab_order.history_target(button.direction) is None + button.disabled = unavailable button.set_class(unavailable, "-unavailable") button.tooltip = ( "No earlier visited tab" if button.direction == -1 else "No later visited tab" From d3a997ab7d8613233309bb951b821fb3ec47734a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 11:03:01 -0400 Subject: [PATCH 11/14] Preserve native copy selection and modal shell-entry eligibility --- pyproject.toml | 2 +- src/toad/prompt_actions.py | 2 +- src/toad/widgets/prompt.py | 2 +- uv.lock | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3b1f393ed..2e5fe933d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "bbd3b1710c08911beacc2d1666669456a7682658" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "d15dfbf21e4a96d898ffdb3817609b97c0cacb76" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/src/toad/prompt_actions.py b/src/toad/prompt_actions.py index 495bd2546..1b7e38507 100644 --- a/src/toad/prompt_actions.py +++ b/src/toad/prompt_actions.py @@ -56,7 +56,7 @@ class ClearInputAction(KeyboundAction, PromptAction): show = False def available(self, editor): - return bool(editor.text) + return bool(editor.text) and not editor.selected_text async def apply(self, editor): editor.editing_mode.suspend(editor) diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index a8fac949f..34c123320 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -210,7 +210,7 @@ def queue_supported(self) -> bool: @property def can_enter_shell(self) -> bool: - return not self.text and not self.simple_input and not self.shell_mode + return self.submission_allowed and not self.text and not self.simple_input and not self.shell_mode def watch_slash_commands(self, slash_commands: list[SlashCommand]) -> None: self.notify_style_update() diff --git a/uv.lock b/uv.lock index 9e42069b9..fd0547a32 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=d15dfbf21e4a96d898ffdb3817609b97c0cacb76" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8#bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=d15dfbf21e4a96d898ffdb3817609b97c0cacb76#d15dfbf21e4a96d898ffdb3817609b97c0cacb76" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 4a374eb89dd63c1b072394945ae864dad35d2d93 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 11:07:53 -0400 Subject: [PATCH 12/14] Let the Ctrl-C action arbitrate native copy before draft clearing --- src/toad/prompt_actions.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/toad/prompt_actions.py b/src/toad/prompt_actions.py index 1b7e38507..4bb6f6c4c 100644 --- a/src/toad/prompt_actions.py +++ b/src/toad/prompt_actions.py @@ -48,17 +48,20 @@ async def apply(self, editor): editor.schedule_submission(immediate=True) -class ClearInputAction(KeyboundAction, PromptAction): +class CopyOrClearInputAction(KeyboundAction, PromptAction): key = "ctrl+c" - description = "Clear" - tooltip = "Clear the prompt" + description = "Copy / Clear" + tooltip = "Copy selected text or clear the prompt" priority = True show = False def available(self, editor): - return bool(editor.text) and not editor.selected_text + return bool(editor.text) async def apply(self, editor): + if editor.selected_text: + editor.action_copy() + return editor.editing_mode.suspend(editor) editor.clear() editor.editing_mode.resume(editor) From 8c264fb0478354def9089ff8234eaef0f4cec79f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 12:05:08 -0400 Subject: [PATCH 13/14] Pin hint custody-before-mount correction --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2e5fe933d..427004bb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "bbd3b1710c08911beacc2d1666669456a7682658" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "d15dfbf21e4a96d898ffdb3817609b97c0cacb76" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "9a18a0f0fe41a2d5edde25f9e2371de1c3877559" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/uv.lock b/uv.lock index fd0547a32..5327b5c3f 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=d15dfbf21e4a96d898ffdb3817609b97c0cacb76" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=9a18a0f0fe41a2d5edde25f9e2371de1c3877559" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=d15dfbf21e4a96d898ffdb3817609b97c0cacb76#d15dfbf21e4a96d898ffdb3817609b97c0cacb76" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=9a18a0f0fe41a2d5edde25f9e2371de1c3877559#9a18a0f0fe41a2d5edde25f9e2371de1c3877559" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From c6a54828076a4fedd5d91a11679d6f41af8f3532 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 21:35:18 -0400 Subject: [PATCH 14/14] Pin single-owner hint lifecycle on the integrated Vim pair --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b3386ea19..402feec0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "5e58d8b5907cec4dbc8810a9329aaf902f69f675" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "9a18a0f0fe41a2d5edde25f9e2371de1c3877559" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "18f4e7a67403d56846ef5abf55c93e3e0639be33" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } [project.scripts] diff --git a/uv.lock b/uv.lock index 17c430aa5..91bd434fa 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=9a18a0f0fe41a2d5edde25f9e2371de1c3877559" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=18f4e7a67403d56846ef5abf55c93e3e0639be33" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], specifier = "==0.14.1" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1618,7 +1618,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=9a18a0f0fe41a2d5edde25f9e2371de1c3877559#9a18a0f0fe41a2d5edde25f9e2371de1c3877559" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=18f4e7a67403d56846ef5abf55c93e3e0639be33#18f4e7a67403d56846ef5abf55c93e3e0639be33" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" },