Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/toad/application_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
24 changes: 13 additions & 11 deletions src/toad/neovim_editing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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))

Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/toad/widget_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
Expand All @@ -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, ...]:
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/toad/widgets/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 32 additions & 1 deletion tests/textual/test_active_mode_keymap.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
96 changes: 96 additions & 0 deletions tests/textual/test_prompt_enter_insert.py
Original file line number Diff line number Diff line change
@@ -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")
Loading