From 999558c82c8a3da12c90809605bbf88f26e534b5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:40:02 -0400 Subject: [PATCH 1/3] Project the Neovim cursor atomically and stop waiting on key replies --- src/toad/neovim_editing.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 0b5072520..f51cbc259 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -379,7 +379,7 @@ def visit(self, action: Callable[[TextArea], object]) -> None: def boundary(self, binding: Binding) -> bool: return False - def project(self, changes: Sequence[NeovimChange]) -> None: + def project(self, changes: Sequence[NeovimChange], cursor: Location) -> None: pass def finish_projection(self) -> None: @@ -400,7 +400,7 @@ def prepare_editor(self, editor: TextArea) -> None: if editor.document is not self.document: editor.set_document(self.document) - def project(self, changes: Sequence[NeovimChange]) -> None: + def project(self, changes: Sequence[NeovimChange], cursor: Location) -> None: for change in changes: self.project_edit(change.apply(self.document)) @@ -416,6 +416,13 @@ def __init__(self, editor: TextArea) -> None: def document(self) -> DocumentBase: return self._editor.document + def project(self, changes: Sequence[NeovimChange], cursor: Location) -> None: + """Publish the document and its cursor before size-dependent effects.""" + super().project(changes, cursor) + if self._editor.cursor_location != cursor: + self._editor.paint_selection(Selection.cursor(cursor)) + self._editor.record_cursor_width() + def project_edit(self, result: RangedEditResult) -> None: self._editor.project_engine_edit(result) @@ -831,6 +838,7 @@ def attach(self, editor: TextArea) -> None: self._rendered = self._rendered.unpainted() started = self._started if started.done() and not started.cancelled(): + self._receiver.project((), self.snapshot.state.cursor) self.paint(editor) def resume(self, editor: TextArea) -> None: @@ -1068,8 +1076,7 @@ def accept_display(self, display: NeovimDisplay) -> None: def publish(self, snapshot: NeovimSnapshot) -> None: """Project preceding lines before exposing the display that describes them.""" changes, self._changes = self._changes, [] - if changes: - self._receiver.project(changes) + self._receiver.project(changes, snapshot.state.cursor) self.snapshot = snapshot if changes: self._receiver.finish_projection() @@ -1098,9 +1105,6 @@ def render(self, editor: TextArea) -> None: if self.snapshot is self._rendered: return previous, current = self._rendered, self.snapshot.state - if not previous.matches(current, lambda state: state.cursor): - editor.paint_selection(Selection.cursor(current.cursor)) - editor.record_cursor_width() if not previous.matches(current, lambda state: state.regions): editor.invalidate_engine_regions((*previous.state.regions, *current.regions)) if not previous.matches(current, lambda state: (state.label, state.command_line)): @@ -1150,10 +1154,8 @@ def ready() -> None: return await finished async def handle_key(self, editor: TextArea, event: Key) -> bool: - try: - await self._input(self.input_code(event.key, event.character or "")) - except EditingFailure: - pass # The child/wire failure was reported; never replay input. + """Queue input; the existing request owner observes its reply and failure.""" + self._input(self.input_code(event.key, event.character or "")) return True @staticmethod From c0bcfe6f9fbdad6f9418c8c3f7e8ae94f6f575a5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 22:03:08 -0400 Subject: [PATCH 2/3] Declare app shortcut priority and cover real Neovim prompt transitions --- src/toad/application_actions.py | 2 - src/toad/widget_actions.py | 7 +- tests/textual/test_active_mode_keymap.py | 33 +++++++- tests/textual/test_prompt_enter_insert.py | 96 +++++++++++++++++++++++ 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index fb485242d..4ee03a480 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -166,7 +166,6 @@ class KeymapAction(ApplicationAction): """Browse keyboard shortcuts and change their keys for each editing mode.""" keys = {EditingMode: ("f1",)} - priority = True async def apply(self, app: ToadApp) -> None: focused = frozenset(binding.id for node, bindings in app.screen._binding_chain @@ -278,7 +277,6 @@ class QuitAction(ApplicationAction): """Save preferences and quit the application.""" keys = {EditingMode: ("ctrl+q",)} - priority = True async def apply(self, app: ToadApp) -> None: app.application.quit() diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index 8bd6325d8..aaefaa9f5 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -162,6 +162,8 @@ class YieldingKey: """An application key that any binding closer to the focus replaces: it answers only where nothing local claims the key, so the claim is no conflict.""" + priority = False + @classmethod def exclusive_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: return () @@ -177,7 +179,10 @@ def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool: class ApplicationAction(Action, affix="Action"): + """Declared application shortcuts resolve before an editor takes the key.""" + binding_prefix = "app." + priority = True @classmethod def exclusive_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: @@ -193,8 +198,6 @@ def menu(cls, app): class VimWorkspaceAction(ApplicationAction, declared_name=None): """Workspace commands precede editor bindings only in idle modal command entry.""" - priority = True - def key_available(self, app): if app.current_mode != "workspace": return False diff --git a/tests/textual/test_active_mode_keymap.py b/tests/textual/test_active_mode_keymap.py index 19b8362b3..dc8bf989c 100644 --- a/tests/textual/test_active_mode_keymap.py +++ b/tests/textual/test_active_mode_keymap.py @@ -1,9 +1,11 @@ """Application remaps work from shared sidebar focus and the Close button.""" +import pytest from textual.widgets import Input from toad.application_actions import CommanderAction -from toad.key_modes import VimNormal +from toad.key_modes import KeyMode, Standard, VimNormal +from toad.neovim_editing import NeovimEditing from toad.preferences import SETTINGS from toad.screens.commander import CommanderPage from toad.widgets.side_bar import SideBar @@ -35,3 +37,32 @@ async def test_commander_remaps_from_sidebar_and_close_button(workspace): await ui.pilot.click("#commander-close") await ui.until(lambda: app.screen is app.workspace_screen, why="the Close button uses the same dismissal action") + + +@pytest.mark.parametrize("mode", KeyMode.members_with(NeovimEditing)) +async def test_application_binding_precedes_real_engine_in_each_profile(workspace, mode): + async with workspace.app(size=(120, 40)) as ui: + app = ui.app + area = ui.conversation.prompt.prompt_text_area + app.saved_settings.set(SETTINGS.ui.key_mode, Standard) + await ui.until(lambda: area.key_mode is Standard, why="the native draft editor") + area.focus() + await ui.type("keep draft") + app.saved_settings.set(SETTINGS.ui.key_mode, mode) + await ui.until(lambda: area.key_mode is mode, why="the declared engine profile") + await ui.until(lambda: area.editing_mode.completed, + why="the real native mode before the application shortcut") + await ui.press("f2") + await ui.until(lambda: isinstance(app.screen, CommanderPage), + why="the default commander key before engine forwarding") + await ui.pilot.click("#commander-close") + await ui.until(lambda: app.screen is app.workspace_screen, why="the original prompt again") + area.focus() + assert area.text == "keep draft" + app.set_keymap({CommanderAction.binding_id(mode): "f3"}) + await ui.press("f3") + await ui.until(lambda: isinstance(app.screen, CommanderPage), + why="the configured application key through the same binding owner") + await ui.pilot.click("#commander-close") + await ui.until(lambda: app.screen is app.workspace_screen, why="the retained prompt") + assert area.text == "keep draft" diff --git a/tests/textual/test_prompt_enter_insert.py b/tests/textual/test_prompt_enter_insert.py index d73c4be36..39b5d23fb 100644 --- a/tests/textual/test_prompt_enter_insert.py +++ b/tests/textual/test_prompt_enter_insert.py @@ -1,8 +1,104 @@ """Textual-facing: vim `i` asks the prompt for insert mode; the prompt owns whether its editor exists.""" from agent_comms.repo import Checkout +from support.agents import peer +from toad.key_modes import Standard, VimInsert +from toad.preferences import SETTINGS +from toad.screens.settings import SettingsScreen +from toad.session_scope import AgentAttached from toad.widgets.prompt import MessagePrompt def test_enter_insert_before_compose_has_no_editor_to_touch(tmp_path): MessagePrompt(project=Checkout(tmp_path)).enter_insert() + + +async def test_real_neovim_submission_shrinks_document_and_cursor_together(workspace): + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: + await ui.until_agent_ready() + app = ui.app + prompt = ui.conversation.prompt + area = prompt.prompt_text_area + app.saved_settings.set(SETTINGS.ui.key_mode, VimInsert) + await ui.until(lambda: area.key_mode is VimInsert, + why="the declared Neovim insert profile") + await ui.until(lambda: area.editing_mode.completed, + why="the real child to finish its initial document") + area.focus() + await ui.type("first") + await ui.press("shift+enter") + await ui.type("second") + await ui.until(lambda: (area.text == "first\nsecond" + and area.cursor_location == (1, 6) + and area.editing_mode.completed), + why="the real second-line cursor before submission") + await ui.press("enter") + await ui.until(lambda: workspace.prompts_received() == ["first\nsecond"], + why="the actual private peer to receive the submitted text") + await ui.until(lambda: (area.text == "" and area.cursor_location == (0, 0) + and area.editing_mode.completed), + why="the cleared document and cursor to publish together") + await ui.type("still here") + await ui.until(lambda: area.text == "still here" and area.editing_mode.completed, + why="the same prompt to accept input after shrinking") + await ui.press("ctrl+c") + await ui.until(lambda: area.text == "" and area.editing_mode.completed, + why="the local clear command, not the yielding app quit hint") + + +async def test_standard_chooser_retires_neovim_and_saved_selection_reopens(workspace): + async with workspace.app() as ui: + app = ui.app + prompt = ui.conversation.prompt + area = prompt.prompt_text_area + app.saved_settings.set(SETTINGS.ui.key_mode, VimInsert) + await ui.until(lambda: area.key_mode is VimInsert, + why="the declared Neovim profile") + await ui.until(lambda: area.editing_mode.completed, + why="a completed real child before selecting Standard") + previous = area.editing_mode._child + area.focus() + await ui.type("retained draft") + await ui.until(lambda: area.text == "retained draft" and area.editing_mode.completed, + why="a real draft to transfer to the native editor") + await ui.press("ctrl+comma") + await ui.until(lambda: isinstance(app.screen, SettingsScreen), + why="the application settings binding before Neovim") + await ui.until(lambda: str(SETTINGS.ui.key_mode) in app.screen.form.values, + why="the existing declared mode chooser") + chooser = app.screen.form.values[str(SETTINGS.ui.key_mode)].editor + assert chooser.lists(Standard) + chooser.value = Standard + await ui.until(lambda: app.settings.ui.key_mode is Standard and area.key_mode is Standard, + why="Standard selected through the real settings editor") + assert previous.returncode is not None + assert area.text == "retained draft" + await ui.press("escape") + await ui.until(lambda: app.screen is app.workspace_screen and not app.saved_settings.unsaved, + why="the selected profile saved after closing settings") + area.focus() + await ui.until(lambda: str(prompt.editing_mode_label.render()) == Standard.label, + why="the visible Standard label at the focused native editor") + await ui.type(" kept") + assert area.text == "retained draft kept" + app.saved_settings.set(SETTINGS.ui.key_mode, VimInsert) + await ui.until(lambda: area.key_mode is VimInsert, + why="returning to the declared engine profile") + await ui.until(lambda: area.editing_mode.completed, + why="the new generation's real child and transferred draft") + current = area.editing_mode._child + assert current.identity != previous.identity + assert current.returncode is None + assert area.text == "retained draft kept" + app.saved_settings.set(SETTINGS.ui.key_mode, Standard) + await ui.until(lambda: area.key_mode is Standard, + why="retiring the second child through the same mode owner") + assert current.returncode is not None + await app.saved_settings.save() + async with workspace.app() as ui: + await ui.until(lambda: ui.conversation.prompt.prompt_text_area.key_mode is Standard, + why="the actual saved Standard preference after reopen") + area = ui.conversation.prompt.prompt_text_area + area.focus() + await ui.until(lambda: str(ui.conversation.prompt.editing_mode_label.render()) == Standard.label, + why="the saved Standard label, without starting the engine") From 86cf8382b5adebf5e87f5610258fad8a2f570a2e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 22:44:41 -0400 Subject: [PATCH 3/3] Reconcile the selected editor profile when native input completes --- src/toad/widgets/prompt.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 6b51387b3..fc2c49be3 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -294,9 +294,12 @@ async def _apply_keyboard_profile(self) -> None: if self.key_mode is not mode: await self.switch_editing_mode(mode()) - def on_neovim_updated(self, event: NeovimUpdated) -> None: + async def on_neovim_updated(self, event: NeovimUpdated) -> None: event.stop() event.owner.paint(self) + # A requested profile can outlive a refused transfer while RPC is pending. + # The existing completion update retries from the saved preference. + await self._apply_keyboard_profile() @property def submission_allowed(self) -> bool: