From 04e2b73dd1f944a07ec7669012a442b942b60523 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 01:51:42 -0400 Subject: [PATCH 01/27] Embed real Neovim through TextArea editing ownership --- pyproject.toml | 1 + src/toad/key_modes.py | 33 +-- src/toad/neovim_editing.lua | 65 +++++ src/toad/neovim_editing.py | 467 +++++++++++++++++++++++++++++++ src/toad/prompt_cursor.py | 15 +- src/toad/session_presentation.py | 10 +- src/toad/widgets/prompt.py | 37 ++- 7 files changed, 586 insertions(+), 42 deletions(-) create mode 100644 src/toad/neovim_editing.lua create mode 100644 src/toad/neovim_editing.py diff --git a/pyproject.toml b/pyproject.toml index f709fc75e..0c7948e4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "ui-theme", "formbridge==0.2.0", "objectstate==5.0.0", + "msgpack==1.2.2", ] [tool.uv.workspace] diff --git a/src/toad/key_modes.py b/src/toad/key_modes.py index 031f56f2a..7020e8b79 100644 --- a/src/toad/key_modes.py +++ b/src/toad/key_modes.py @@ -1,35 +1,32 @@ -"""Keyboard profiles compose the native editor modes; no second editor state.""" +"""Saved profiles select an editing owner, not a handwritten command subset.""" from __future__ import annotations from agent_comms.declared_choice import DeclaredChoice -from textual.document._editing import ( - EditingMode, - VimInsert as InsertEditing, - VimMode as ModalEditing, - VimNormal as NormalEditing, - VimVisual as VisualEditing, -) +from textual.document._editing import EditingMode +from toad.neovim_editing import NeovimEditing + class KeyMode(EditingMode, DeclaredChoice, affix=""): - """A preference selects the initial native mode, whose own transitions keep editing.""" + """The saved preference selects native editing or a real Neovim initial state.""" + class Standard(KeyMode): - """Ordinary text entry and navigation.""" + """Ordinary native text entry and navigation.""" label = "STANDARD" -class VimMode(KeyMode, ModalEditing): - """The shared modal-key capability; the native key handler keeps this abstract.""" +class VimInsert(NeovimEditing, KeyMode): + """Start Neovim in Insert mode.""" + initial_keys = "i" -class VimInsert(VimMode, InsertEditing): - """Start modal editing in Insert.""" +class VimNormal(NeovimEditing, KeyMode): + """Start Neovim in Normal mode.""" -class VimNormal(VimMode, NormalEditing): - """Start modal editing in Normal.""" +class VimVisual(NeovimEditing, KeyMode): + """Start Neovim in Visual mode.""" -class VimVisual(VimNormal, VisualEditing): - """Start modal editing in Visual.""" + initial_keys = "v" diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua new file mode 100644 index 000000000..2a8ce4975 --- /dev/null +++ b/src/toad/neovim_editing.lua @@ -0,0 +1,65 @@ +-- Application integration, not a Vim interpreter. Neovim interprets every key. +local mappings = ... +local api = vim.api +local namespace = api.nvim_create_namespace('toad-editor') +vim.opt.swapfile = false +vim.opt.undofile = false +vim.opt.writebackup = false +vim.opt.backup = false +vim.opt.modeline = false +vim.opt.hidden = true +vim.opt.clipboard = '' +vim.opt.virtualedit = 'onemore' +vim.opt.shortmess:append('I') +vim.bo.buftype = 'nofile' +vim.bo.bufhidden = 'hide' + +local function state() + local cursor = vim.fn.getpos('.') + local anchor = vim.fn.getpos('v') + return {{cursor[2], cursor[3]}, {anchor[2], anchor[3]}, + vim.fn.mode(1), vim.fn.state(), vim.v.count, vim.fn.getcmdline()} +end +local function publish() + vim.rpcnotify(1, 'toad_editor_state', unpack(state())) +end +api.nvim_create_autocmd({'ModeChanged', 'CursorMoved', 'CursorMovedI', + 'TextChanged', 'TextChangedI', 'CmdlineChanged', 'SafeState'}, + {callback = publish}) +vim.on_key(function() vim.schedule(publish) end, namespace) + +-- Install the actual native arrow declarations, including saved aliases, inside +-- Neovim. Its mapping engine knows that the k in fk is an operand, not Up. +for _, mapping in ipairs(mappings) do + local lhs, rhs, native = unpack(mapping) + for _, mode in ipairs({'', 'i'}) do + vim.keymap.set(mode, lhs, function() + if vim.v.count == 0 and not vim.fn.mode(1):match('^no') then + if vim.rpcrequest(1, 'toad_cursor_boundary', native) then return '' end + end + return api.nvim_replace_termcodes(rhs, true, false, true) + end, {expr = true, noremap = true, silent = true}) + end +end + +ToadEditor = {} +function ToadEditor.load(text) + api.nvim_buf_set_lines(0, 0, -1, true, vim.split(text, '\n', {plain = true})) + local lines = api.nvim_buf_get_lines(0, 0, -1, true) + api.nvim_win_set_cursor(0, {#lines, #lines[#lines]}) +end +function ToadEditor.replace(top, top_byte, bottom, bottom_byte, lines, maintain_offset) + local cursor = api.nvim_win_get_cursor(0) + local mark = api.nvim_buf_set_extmark(0, namespace, cursor[1] - 1, cursor[2], + {right_gravity = true}) + api.nvim_buf_set_text(0, top, top_byte, bottom, bottom_byte, lines) + local retained = api.nvim_buf_get_extmark_by_id(0, namespace, mark, {}) + api.nvim_buf_del_extmark(0, namespace, mark) + if maintain_offset then + api.nvim_win_set_cursor(0, {retained[1] + 1, retained[2]}) + else + local row = top + #lines - 1 + local column = #lines[#lines] + (#lines == 1 and top_byte or 0) + api.nvim_win_set_cursor(0, {row + 1, column}) + end +end diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py new file mode 100644 index 000000000..d29523461 --- /dev/null +++ b/src/toad/neovim_editing.py @@ -0,0 +1,467 @@ +"""Real modal editing. Neovim owns the buffer, registers, undo and input grammar.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextlib import AsyncExitStack +from dataclasses import dataclass +from functools import cached_property +from importlib.resources import files +from typing import TYPE_CHECKING +from weakref import ref + +import msgpack +from agent_comms.child_process import AttachedChild, join_retirement +from textual.binding import Binding +from textual.cursor_shape import BlinkingBar, SteadyBlock +from textual.document._document import Selection +from textual.document._editing import VimMode +from textual.message import Message + +if TYPE_CHECKING: + from textual.document._edit import Edit + from textual.events import Key + from textual.widgets import TextArea + + +@dataclass(frozen=True) +class NeovimState: + """Decoded external state, never inferred from the last key.""" + + code: str + cursor: tuple[int, int] + anchor: tuple[int, int] + command_line: str + accepts_text = False + allows_application_commands = False + allows_history_navigation = False + cursor_shape = SteadyBlock() + + @property + def label(self) -> str: + return self.code.upper() + + @classmethod + def decode(cls, value: object, lines: tuple[str, ...]) -> NeovimState: + if not isinstance(value, list) or len(value) != 6: + raise ValueError("Invalid Neovim editor state") + cursor, anchor, code, state, count, command_line = value + if not (isinstance(code, str) and isinstance(state, str) + and isinstance(count, int) and isinstance(command_line, str)): + raise ValueError("Invalid Neovim mode state") + + def location(position: object) -> tuple[int, int]: + if not (isinstance(position, list) and len(position) == 2 + and all(isinstance(n, int) for n in position)): + raise ValueError("Invalid Neovim byte position") + row, column = position + row = max(0, min(row - 1, len(lines) - 1)) + return row, len(lines[row].encode("utf-8")[:max(0, column - 1)].decode("utf-8")) + + args = code, location(cursor), location(anchor), command_line + # This is the external Neovim taxonomy boundary, not a key interpreter. + if code.startswith("i"): + return InsertState(*args) + if code == "n" and count == 0 and not any(flag in state for flag in "omS"): + return NormalState(*args) + if code == "v": + return VisualState(*args) + if code == "V": + return LineVisualState(*args) + if code == "\x16": + return BlockVisualState(*args) + if code.startswith("R"): + return ReplaceState(*args) + return cls(*args) + + def selection(self, lines: tuple[str, ...]) -> Selection: + return Selection.cursor(self.cursor) + + def selection_for_line(self, lines: tuple[str, ...], row: int) -> Selection: + return self.selection(lines) + + +class InsertState(NeovimState): + label = "INSERT" + accepts_text = True + allows_history_navigation = True + cursor_shape = BlinkingBar() + + +class NormalState(NeovimState): + label = "NORMAL" + allows_application_commands = True + allows_history_navigation = True + + +class ReplaceState(NeovimState): + label = "REPLACE" + accepts_text = True + cursor_shape = BlinkingBar() + + +class VisualState(NeovimState): + label = "VISUAL" + + def selection(self, lines: tuple[str, ...]) -> Selection: + start, end = sorted((self.anchor, self.cursor)) + return Selection(start, (end[0], min(len(lines[end[0]]), end[1] + 1))) + + +class LineVisualState(VisualState): + label = "VISUAL LINE" + + def selection(self, lines: tuple[str, ...]) -> Selection: + start, end = sorted((self.anchor, self.cursor)) + return Selection((start[0], 0), (end[0], len(lines[end[0]]))) + + +class BlockVisualState(VisualState): + label = "VISUAL BLOCK" + + def selection_for_line(self, lines: tuple[str, ...], row: int) -> Selection: + top, bottom = sorted((self.anchor[0], self.cursor[0])) + left, right = sorted((self.anchor[1], self.cursor[1])) + if top <= row <= bottom: + return Selection((row, left), (row, right + 1)) + return Selection.cursor(self.cursor) + + +@dataclass(frozen=True) +class NeovimSnapshot: + owner: NeovimEditing + lines: tuple[str, ...] + state: NeovimState + + def apply(self, editor: TextArea) -> None: + if editor.editing_mode is not self.owner or self is not self.owner.snapshot: + return + text = "\n".join(self.lines) + if editor.text != text: + # A derived rendering document has no independently writable undo stack. + editor.history.clear() + editor._set_document(text, editor.language) + editor.post_message(editor.Changed(editor)) + editor.update_suggestion() + editor.selection = Selection.cursor(self.state.cursor) + editor._line_cache.clear() + editor.record_cursor_width() + editor.show_editing_mode(self.owner) + editor.refresh_bindings() + editor.refresh() + + +class NeovimUpdated(Message): + """One immutable engine result enters the existing widget message loop.""" + + def __init__(self, snapshot: NeovimSnapshot) -> None: + super().__init__() + self.snapshot = snapshot + + +class NeovimEditing(VimMode): + """One lazy embedded child per editor resource, with serialized asynchronous RPC.""" + + initial_keys = "" + + def __init__(self) -> None: + self._receiver: ref[TextArea] = lambda: None + self._pending: dict[int, asyncio.Future[object]] = {} + self._request_id = 0 + self._lines: tuple[str, ...] = ("",) + self.snapshot = NeovimSnapshot(self, self._lines, + NeovimState("NEOVIM STARTING", (0, 0), (0, 0), "")) + self._failure: Exception | None = None + + @property + def label(self) -> str: + if self._failure is not None: + return "NEOVIM FAILED — draft preserved" + state = self.snapshot.state + return f"{state.label} {state.command_line}".rstrip() + + @property + def accepts_text(self) -> bool: + return self._failure is None and self.snapshot.state.accepts_text + + @property + def allows_application_commands(self) -> bool: + return self._failure is None and self.snapshot.state.allows_application_commands + + @property + def allows_history_navigation(self) -> bool: + return self.snapshot.state.allows_history_navigation + + @property + def cursor_shape(self): + return self.snapshot.state.cursor_shape + + @property + def binding_mode(self): + return type(self) + + @cached_property + def _started(self) -> asyncio.Task[None]: + editor = self._receiver() + if editor is None: + raise RuntimeError("Neovim requires an attached editor") + self._initial_text = editor.text + self._initial_selection = editor.selection + self._child_ready = asyncio.get_running_loop().create_future() + self._tail = asyncio.get_running_loop().create_future() + self._tail.set_result(None) + self._lifetime = editor.app.run_worker(self._run(), name="Neovim editor", exit_on_error=False) + return asyncio.create_task(self._start()) + + def attach(self, editor: TextArea) -> None: + self._receiver = ref(editor) + # Starting is lazy: Standard never starts a Neovim process. + started = self._started + started.add_done_callback(self._observe_failure) + if started.done() and not started.cancelled() and started.exception() is None: + self.snapshot.apply(editor) + + def resume(self, editor: TextArea) -> None: + pass + + def suspend(self, editor: TextArea) -> None: + pass + + def capture(self, editor: TextArea) -> NeovimEditing: + self._receiver = lambda: None + return self + + async def unmount(self, editor: TextArea) -> None: + if self._receiver() is editor: + await self.close() + + async def close(self) -> None: + if "_started" not in self.__dict__: + return + self._receiver = lambda: None + self._lifetime.cancel() + await join_retirement(asyncio.create_task(self._join())) + + async def _join(self) -> None: + await asyncio.gather(self._started, self._tail, return_exceptions=True) + try: + await self._lifetime.wait() + except asyncio.CancelledError: + pass + + async def _run(self) -> None: + """The existing child owner handles identity, stderr and process-group teardown.""" + try: + async with AsyncExitStack() as cleanup: + spawn = asyncio.create_task(AttachedChild.start(("nvim", "--embed", "--clean", "-n"))) + try: + child = await asyncio.shield(spawn) + except asyncio.CancelledError: + child = await join_retirement(spawn) + cleanup.push_async_callback(child.stop) + raise + cleanup.push_async_callback(child.stop) + self._child = child + self._child_ready.set_result(child) + assert child.stdout is not None + unpacker = msgpack.Unpacker(raw=False, strict_map_key=False, max_buffer_size=16 * 1024 * 1024) + while chunk := await child.stdout.read(65536): + unpacker.feed(chunk) + for message in unpacker: + await self._receive(message) + raise RuntimeError(f"Neovim exited: {await child.stderr_tail()}") + except asyncio.CancelledError: + raise + except Exception as error: + self._fail(error) + finally: + error = self._failure or RuntimeError("Neovim editor closed; input was not replayed") + if not self._child_ready.done(): + self._child_ready.set_exception(error) + for future in tuple(self._pending.values()): + if not future.done(): + future.set_exception(error) + + async def _start(self) -> None: + await self._child_ready + await self._rpc("nvim_ui_attach", [80, 24, {"ext_linegrid": True, "ext_cmdline": True, "ext_messages": True}]) + self._lines = tuple(self._initial_text.split("\n")) + await self._rpc("nvim_buf_set_lines", [0, 0, -1, True, list(self._lines)]) + await self._rpc("nvim_win_set_cursor", [0, self._byte_cursor(self._initial_selection.end)]) + await self._rpc("nvim_buf_attach", [0, True, {}]) + script = files("toad").joinpath("neovim_editing.lua").read_text() + editor = self._receiver() + mappings: list[list[str]] = [] + if editor is not None: + for key, binding in editor.binding_map(type(self)): + for spelling, native in binding.declared_key.input_bindings(key): + mappings.append([self.input_code(spelling), self.input_code(native), native]) + await self._rpc("nvim_exec_lua", [script, [mappings]]) + if self.initial_keys: + await self._rpc("nvim_input", [self.initial_keys]) + await self._snapshot() + + async def _rpc(self, method: str, arguments: list[object]) -> object: + if self._failure is not None: + raise self._failure + self._request_id += 1 + identity = self._request_id + future = self._pending[identity] = asyncio.get_running_loop().create_future() + try: + await self._child.write(msgpack.packb([0, identity, method, arguments], use_bin_type=True)) + async with asyncio.timeout(5): + return await future + finally: + self._pending.pop(identity, None) + + async def _receive(self, message: object) -> None: + """Decode MessagePack-RPC once. All type tests below concern foreign wire data.""" + if not isinstance(message, list) or not message or not isinstance(message[0], int): + raise ValueError("Invalid Neovim RPC message") + if message[0] == 1 and len(message) == 4: + _, identity, error, result = message + if not isinstance(identity, int) or identity not in self._pending: + raise ValueError("Unexpected Neovim RPC response") + future = self._pending[identity] + if error is None: + future.set_result(result) + else: + future.set_exception(RuntimeError(f"Neovim RPC failed: {error}")) + elif message[0] == 2 and len(message) == 3: + _, method, arguments = message + if method == "nvim_buf_lines_event": + if not isinstance(arguments, list) or len(arguments) != 6: + raise ValueError("Invalid Neovim buffer notification") + _, tick, first, last, lines, _more = arguments + if not (isinstance(first, int) and isinstance(last, int) and isinstance(lines, list) + and all(isinstance(line, str) for line in lines)): + raise ValueError("Invalid Neovim line update") + if tick is not None: + stop = len(self._lines) if last == -1 else last + self._lines = self._lines[:first] + tuple(lines) + self._lines[stop:] + elif method == "toad_editor_state": + state = NeovimState.decode(arguments, self._lines) + self.snapshot = NeovimSnapshot(self, self._lines, state) + if (editor := self._receiver()) is not None and editor.is_attached: + editor.post_message(NeovimUpdated(self.snapshot)) + # Grid data is deliberately not scraped. Text comes from buffer events. + elif message[0] == 0 and len(message) == 4: + _, identity, method, arguments = message + if method != "toad_cursor_boundary" or not isinstance(arguments, list) or len(arguments) != 1 or not isinstance(arguments[0], str): + await self._child.write(msgpack.packb([1, identity, "Unsupported Neovim host request", None])) + return + editor = self._receiver() + handled = editor is not None and editor.is_attached and editor.before_engine_input(arguments[0]) + await self._child.write(msgpack.packb([1, identity, None, handled])) + else: + raise ValueError("Invalid Neovim RPC envelope") + + def _byte_cursor(self, location: tuple[int, int]) -> list[int]: + row, column = location + row = max(0, min(row, len(self._lines) - 1)) + return [row + 1, len(self._lines[row][:column].encode("utf-8"))] + + async def _snapshot(self) -> None: + raw = await self._rpc("nvim_eval", ["[getpos('.')[1:2],getpos('v')[1:2],mode(1),state(),v:count,getcmdline()]"]) + self.snapshot = NeovimSnapshot(self, self._lines, NeovimState.decode(raw, self._lines)) + if (editor := self._receiver()) is not None and editor.is_attached: + editor.post_message(NeovimUpdated(self.snapshot)) + + def _enqueue(self, operation: Callable[[], Awaitable[object]]) -> asyncio.Task[None]: + previous = self._tail + + async def run() -> None: + await self._started + await previous + await operation() + await self._snapshot() + + task = asyncio.create_task(run()) + self._tail = task + task.add_done_callback(self._observe_failure) + return task + + def _observe_failure(self, task: asyncio.Future[None]) -> None: + if not task.cancelled() and (error := task.exception()) is not None: + self._fail(error) + + def _fail(self, error: Exception) -> None: + if self._failure is None: + self._failure = error + if (editor := self._receiver()) is not None: + editor.notify(f"{error}. The displayed draft is preserved. Input was not replayed.", + title="Neovim editing failed", severity="error", timeout=0) + editor.show_editing_mode(self) + + async def flush(self, editor: TextArea) -> None: + await self._started + await self._tail + if self._failure is not None: + raise self._failure + self.snapshot.apply(editor) + + async def prepare_input(self, editor: TextArea) -> None: + finished = asyncio.get_running_loop().create_future() + + async def ready() -> None: + try: + await self.flush(editor) + except Exception as error: + finished.set_exception(error) + else: + finished.set_result(None) + + editor.call_later(ready) + await finished + + async def handle_key(self, editor: TextArea, event: Key) -> bool: + await self._enqueue(lambda: self._rpc("nvim_input", [self.input_code(event.key, event.character)])) + self.snapshot.apply(editor) + return True + + @staticmethod + def input_code(key: str, character: str | None = None) -> str: + """Textual's external key vocabulary enters Neovim at this one boundary.""" + if character is not None and character.isprintable(): + return character.replace("<", "") + if len(key) == 1: + return key.replace("<", "") + *modifiers, name = key.split("+") + names = {"enter": "CR", "escape": "Esc", "backspace": "BS", "delete": "Del", + "pageup": "PageUp", "pagedown": "PageDown", "space": "Space"} + prefixes = {"ctrl": "C", "shift": "S", "alt": "A", "meta": "M", "super": "D"} + return "<" + "-".join([*(prefixes[part] for part in modifiers), names.get(name, name)]) + ">" + + def enter_text(self, editor: TextArea) -> None: + self._enqueue(lambda: self._rpc("nvim_input", ["i"])) + + def load_text(self, editor: TextArea, text: str) -> None: + self._enqueue(lambda: self._rpc("nvim_exec_lua", ["return ToadEditor.load(...)", [text]])) + + def edit(self, editor: TextArea, edit: Edit) -> None: + async def replace() -> object: + top_row, top_byte = self._byte_cursor(edit.top) + bottom_row, bottom_byte = self._byte_cursor(edit.bottom) + return await self._rpc("nvim_exec_lua", ["return ToadEditor.replace(...)", + [top_row - 1, top_byte, bottom_row - 1, bottom_byte, + edit.text.split("\n"), edit.maintain_selection_offset]]) + self._enqueue(replace) + + def paste(self, editor: TextArea, text: str) -> None: + self._enqueue(lambda: self._rpc("nvim_paste", [text, True, -1])) + + def undo(self, editor: TextArea) -> None: + self._enqueue(lambda: self._rpc("nvim_command", ["undo"])) + + def redo(self, editor: TextArea) -> None: + self._enqueue(lambda: self._rpc("nvim_command", ["redo"])) + + def selection_changed(self, editor: TextArea, selection: Selection) -> None: + if selection.end != self.snapshot.state.cursor: + self._enqueue(lambda: self._rpc("nvim_win_set_cursor", [0, self._byte_cursor(selection.end)])) + + def selection_range(self, editor: TextArea) -> Selection: + return self.snapshot.state.selection(self.snapshot.lines) + + def selection_for_line(self, editor: TextArea, row: int) -> Selection: + return self.snapshot.state.selection_for_line(self.snapshot.lines, row) diff --git a/src/toad/prompt_cursor.py b/src/toad/prompt_cursor.py index 45eb0a564..a594a6de0 100644 --- a/src/toad/prompt_cursor.py +++ b/src/toad/prompt_cursor.py @@ -8,10 +8,11 @@ from agent_comms.declared_family import DeclaredFamily from textual.content import Content +from textual.keys import Keys from textual.widgets.text_area import Selection if TYPE_CHECKING: - from textual.document._editing import EditingMode, LineMotion, VimNormal + from textual.document._editing import EditingMode from toad.slash_command import SlashCommand from toad.widgets.prompt import PromptTextArea @@ -81,6 +82,7 @@ def retreat(self, editor, movement): class HistoryCursor(DeclaredFamily, affix="Cursor"): direction: int + key: Keys @classmethod @abstractmethod @@ -103,16 +105,14 @@ def move(cls, editor: PromptTextArea, select: bool, ordinary: Callable[[bool], o ordinary(select) @classmethod - def move_motion(cls, editor: PromptTextArea, mode: VimNormal, motion: type[LineMotion]) -> bool: - # Direction is the native vertical displacement, not a mode/key name. - # New counted vertical motions borrow the same two history boundaries. - cursor = next(cursor for cursor in cls.members_with(cls) - if cursor.direction == motion.direction) - return cursor.try_move(editor, False, mode) + def try_input(cls, editor: PromptTextArea, key: str) -> bool: + return any(cursor.try_move(editor, False, editor.editing_mode) + for cursor in cls.members_with(cls) if cursor.key.value == key) class PreviousHistoryCursor(HistoryCursor): direction = -1 + key = Keys.Up @classmethod def at_edge(cls, editor): @@ -121,6 +121,7 @@ def at_edge(cls, editor): class NextHistoryCursor(HistoryCursor): direction = 1 + key = Keys.Down @classmethod def at_edge(cls, editor): diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index e13b2259c..cb5b2648f 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -62,10 +62,10 @@ def prepare_transcript(self, conversation: Conversation) -> None: conversation.visible_categories = self.visible_categories conversation.window.reader_position = self.reader_position - def restore(self, conversation: Conversation) -> None: + async def restore(self, conversation: Conversation) -> None: conversation.input_histories = self.input_histories editor = conversation.prompt.prompt_text_area - editor.restore_editor_state(self.editor) + await editor.restore_editor_state(self.editor) editor.shell_mode = self.shell_mode @@ -381,7 +381,7 @@ async def acquire(self, screen: MainScreen): self.state.prepare_transcript(conversation) await self.transcript.bind(conversation).restore_documents(self.transcript) if self.state is not None: - self.state.restore(conversation) + await self.state.restore(conversation) self.state = None await self.attach_binding(conversation) await self.transcript.bind(conversation).restore(self.transcript) @@ -390,6 +390,8 @@ async def acquire(self, screen: MainScreen): conversation.prompt.focus() async def close(self, screen: MainScreen) -> None: + if self.state is not None: + await self.state.editor.editing_mode.close() self.state = None async with AsyncResourceRelease() as release: release.push_async_callback(self.sources.close) @@ -400,6 +402,8 @@ async def evict(self) -> None: """Release the owned native tree while retaining its actual editor resource.""" if (conversation := self.widget) is None: return + editor = conversation.prompt.prompt_text_area + await editor.editing_mode.flush(editor) self.state = SessionViewState.capture(conversation) await self.remove_native_tree() diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 8a9085b77..269ae48fa 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -18,7 +18,7 @@ from textual.app import ComposeResult from textual.binding import ModeBinding from textual.widgets.text_area import EditingMode, VimMode -from textual.document._editing import LineMotion, VimNormal +from toad.neovim_editing import NeovimUpdated from textual.content import Content, Span from textual.css.scalar import Scalar from textual.geometry import Spacing @@ -281,7 +281,16 @@ def show_editing_mode(self, mode: EditingMode) -> None: self.prompt.editing_mode_label.update(mode.label) def apply_keyboard_profile(self) -> None: - self.editing_mode = self.screen.key_mode() + self.call_later(self._apply_keyboard_profile) + + async def _apply_keyboard_profile(self) -> None: + mode = self.screen.key_mode + if type(self.editing_mode) is not mode: + await self.switch_editing_mode(mode()) + + def on_neovim_updated(self, event: NeovimUpdated) -> None: + event.stop() + event.snapshot.apply(self) @property def submission_allowed(self) -> bool: @@ -302,9 +311,7 @@ def clear_suggestions(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) + self.editing_mode.paste(self, text) @work(group='clipboard-paste', exclusive=True) async def action_paste_clipboard(self) -> None: @@ -376,7 +383,8 @@ async def action_tab_complete(self) -> None: priority=True), ) - def _submit(self) -> None: + async def _submit(self) -> None: + await self.editing_mode.flush(self) self._submit_pending = False immediate, self._submit_immediate = self._submit_immediate, False if not self.has_focus or not self.submission_allowed: @@ -414,9 +422,8 @@ def _submit(self) -> None: if shell: self.prompt.shell_mode = False - def move_line_motion(self, motion: type[LineMotion], mode: VimNormal, count: int) -> None: - if not HistoryCursor.move_motion(self, mode, motion): - super().move_line_motion(motion, mode, count) + def before_engine_input(self, key: str) -> bool: + return HistoryCursor.try_input(self, key) def action_cursor_up(self, select: bool = False): PreviousHistoryCursor.move(self, select, super().action_cursor_up) @@ -540,9 +547,12 @@ def text(self) -> str: @text.setter def text(self, text: str) -> None: self.prompt_text_area.text = text - self.prompt_text_area.selection = Selection.cursor( - self.prompt_text_area.get_cursor_line_end_location() - ) + self.prompt_text_area.call_later(self._finish_text_load) + + async def _finish_text_load(self) -> None: + editor = self.prompt_text_area + await editor.editing_mode.flush(editor) + editor.move_cursor(editor.document.end) @property def supports_completion(self) -> bool: @@ -777,10 +787,9 @@ def enter_insert(self) -> None: """ if not self.is_mounted: return - from toad.key_modes import VimInsert from toad.widgets.conversation import Conversation - self.prompt_text_area.editing_mode = VimInsert() + self.prompt_text_area.editing_mode.enter_text(self.prompt_text_area) self.query_ancestor(Conversation).focus_prompt() MODE_BINDINGS = ( From 2a54b48fa5016165b17603519be2e798a00660d5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:02:24 -0400 Subject: [PATCH 02/27] Keep Neovim input fast and project actual incremental buffer and selection state --- src/toad/keymap.py | 3 +- src/toad/neovim_editing.lua | 46 ++++++- src/toad/neovim_editing.py | 263 ++++++++++++++++++++++++++---------- src/toad/widgets/prompt.py | 9 +- 4 files changed, 242 insertions(+), 79 deletions(-) diff --git a/src/toad/keymap.py b/src/toad/keymap.py index 65d67b9db..70bdc0c8b 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -113,8 +113,7 @@ def capture(cls, modes: tuple[type[KeyMode], ...]) -> KeymapCatalog: for binding in declaration.exclusive_bindings(native_mode)) owned = [application] for owner in DOMNode.contexts(): - defaults = (*owner.declared_bindings(native_mode), - *owner.editor_command_bindings(native_mode)) + defaults = owner.declared_bindings(native_mode) if not issubclass(owner, ModalScreen): defaults = (*defaults, *application) owned.append(defaults) diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua index 2a8ce4975..6d9372f09 100644 --- a/src/toad/neovim_editing.lua +++ b/src/toad/neovim_editing.lua @@ -9,20 +9,49 @@ vim.opt.backup = false vim.opt.modeline = false vim.opt.hidden = true vim.opt.clipboard = '' -vim.opt.virtualedit = 'onemore' vim.opt.shortmess:append('I') vim.bo.buftype = 'nofile' vim.bo.bufhidden = 'hide' local function state() - local cursor = vim.fn.getpos('.') - local anchor = vim.fn.getpos('v') - return {{cursor[2], cursor[3]}, {anchor[2], anchor[3]}, - vim.fn.mode(1), vim.fn.state(), vim.v.count, vim.fn.getcmdline()} + local cursor, anchor = vim.fn.getpos('.'), vim.fn.getpos('v') + local code = vim.fn.mode(1) + local kind = ({v = 'v', V = 'V', ['\022'] = '\022', + s = 'v', S = 'V', ['\019'] = '\022'})[code] + local regions, selected = {}, '' + if kind then + regions = vim.fn.getregionpos(anchor, cursor, {type = kind, eol = true}) + selected = table.concat(vim.fn.getregion(anchor, cursor, {type = kind}), '\n') + if kind == 'V' then selected = selected .. '\n' end + end + return {{cursor[2], cursor[3]}, code, vim.fn.state(), vim.v.count, + vim.fn.getcmdtype() .. vim.fn.getcmdline(), regions, selected} end local function publish() vim.rpcnotify(1, 'toad_editor_state', unpack(state())) end +-- Buffer subscriptions belong to this engine, including :edit/:buffer changes. +-- Only its current buffer publishes rendering deltas; returning to a buffer is +-- an ownership transfer, not another callback registration. +local subscriptions = {} +local function entered(buffer) + vim.rpcnotify(1, 'nvim_buf_lines_event', buffer, + api.nvim_buf_get_changedtick(buffer), 0, -1, + api.nvim_buf_get_lines(buffer, 0, -1, true), false) + if subscriptions[buffer] then return end + subscriptions[buffer] = true + api.nvim_buf_attach(buffer, false, { + on_lines = function(_, buf, tick, first, last, new_last) + if api.nvim_get_current_buf() == buf then + vim.rpcnotify(1, 'nvim_buf_lines_event', buf, tick, first, last, + api.nvim_buf_get_lines(buf, first, new_last, true), false) + end + end, + on_detach = function(_, buf) subscriptions[buf] = nil end, + }) +end +api.nvim_create_autocmd('BufEnter', {callback = function(event) entered(event.buf); publish() end}) +entered(api.nvim_get_current_buf()) api.nvim_create_autocmd({'ModeChanged', 'CursorMoved', 'CursorMovedI', 'TextChanged', 'TextChangedI', 'CmdlineChanged', 'SafeState'}, {callback = publish}) @@ -42,12 +71,17 @@ for _, mapping in ipairs(mappings) do end end -ToadEditor = {} +ToadEditor = {state = state} function ToadEditor.load(text) api.nvim_buf_set_lines(0, 0, -1, true, vim.split(text, '\n', {plain = true})) local lines = api.nvim_buf_get_lines(0, 0, -1, true) api.nvim_win_set_cursor(0, {#lines, #lines[#lines]}) end +function ToadEditor.insert(lines, position, maintain_offset) + position = position or api.nvim_win_get_cursor(0) + return ToadEditor.replace(position[1] - 1, position[2], position[1] - 1, + position[2], lines, maintain_offset) +end function ToadEditor.replace(top, top_byte, bottom, bottom_byte, lines, maintain_offset) local cursor = api.nvim_win_get_cursor(0) local mark = api.nvim_buf_set_extmark(0, namespace, cursor[1] - 1, cursor[2], diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index d29523461..a10d67e2a 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -12,11 +12,11 @@ import msgpack from agent_comms.child_process import AttachedChild, join_retirement -from textual.binding import Binding from textual.cursor_shape import BlinkingBar, SteadyBlock -from textual.document._document import Selection -from textual.document._editing import VimMode +from textual.document._document import Location, Selection +from textual.document._editing import EditingFailure, VimMode from textual.message import Message +from textual.keys import key_to_character if TYPE_CHECKING: from textual.document._edit import Edit @@ -30,8 +30,9 @@ class NeovimState: code: str cursor: tuple[int, int] - anchor: tuple[int, int] command_line: str + regions: tuple[Selection, ...] = () + region_text: str = "" accepts_text = False allows_application_commands = False allows_history_navigation = False @@ -43,9 +44,9 @@ def label(self) -> str: @classmethod def decode(cls, value: object, lines: tuple[str, ...]) -> NeovimState: - if not isinstance(value, list) or len(value) != 6: + if not isinstance(value, list) or len(value) != 7: raise ValueError("Invalid Neovim editor state") - cursor, anchor, code, state, count, command_line = value + cursor, code, state, count, command_line, regions, selected = value if not (isinstance(code, str) and isinstance(state, str) and isinstance(count, int) and isinstance(command_line, str)): raise ValueError("Invalid Neovim mode state") @@ -58,17 +59,46 @@ def location(position: object) -> tuple[int, int]: row = max(0, min(row - 1, len(lines) - 1)) return row, len(lines[row].encode("utf-8")[:max(0, column - 1)].decode("utf-8")) - args = code, location(cursor), location(anchor), command_line + if not isinstance(regions, list) or not isinstance(selected, str): + raise ValueError("Invalid Neovim selected region") + selections: list[Selection] = [] + for region in regions: + if not (isinstance(region, list) and len(region) == 2 + and all(isinstance(pos, list) and len(pos) == 4 for pos in region)): + raise ValueError("Invalid Neovim region position") + start, end = region + first = location(start[1:3]) + final = location(end[1:3]) + selections.append(Selection(first, (final[0], final[1] + 1))) + return cls.from_fields(code, state, count, location(cursor), command_line, + tuple(selections), selected) + + @classmethod + def fast_mode(cls, value: object, previous: NeovimState) -> NeovimState: + if not isinstance(value, dict) or not isinstance(value.get("mode"), str) or not isinstance(value.get("blocking"), bool): + raise ValueError("Invalid Neovim fast mode response") + return cls.from_fields(value["mode"], "S" if value["blocking"] else "", 0, + previous.cursor, previous.command_line, previous.regions, previous.region_text) + + @classmethod + def from_fields(cls, code: str, state: str, count: int, cursor: tuple[int, int], + command_line: str, regions: tuple[Selection, ...], selected: str) -> NeovimState: + args = code, cursor, command_line, regions, selected # This is the external Neovim taxonomy boundary, not a key interpreter. if code.startswith("i"): return InsertState(*args) - if code == "n" and count == 0 and not any(flag in state for flag in "omS"): - return NormalState(*args) - if code == "v": + if code == "n": + return (NormalState if count == 0 and not any(flag in state for flag in "omS") + else PendingNormalState)(*args) + if code.startswith("no"): + return OperatorState(*args) + if code == "c": + return CommandLineState(*args) + if code in ("v", "s"): return VisualState(*args) - if code == "V": + if code in ("V", "S"): return LineVisualState(*args) - if code == "\x16": + if code in ("\x16", "\x13"): return BlockVisualState(*args) if code.startswith("R"): return ReplaceState(*args) @@ -80,6 +110,21 @@ def selection(self, lines: tuple[str, ...]) -> Selection: def selection_for_line(self, lines: tuple[str, ...], row: int) -> Selection: return self.selection(lines) + def selected_text(self) -> str: + return "" + + +class PendingNormalState(NeovimState): + label = "NORMAL (PENDING)" + + +class OperatorState(NeovimState): + label = "OPERATOR PENDING" + + +class CommandLineState(NeovimState): + label = "COMMAND" + class InsertState(NeovimState): label = "INSERT" @@ -104,27 +149,31 @@ class VisualState(NeovimState): label = "VISUAL" def selection(self, lines: tuple[str, ...]) -> Selection: - start, end = sorted((self.anchor, self.cursor)) - return Selection(start, (end[0], min(len(lines[end[0]]), end[1] + 1))) + if not self.regions: + return super().selection(lines) + return Selection(self.regions[0].start, self.regions[-1].end) + + def selection_for_line(self, lines: tuple[str, ...], row: int) -> Selection: + return next((region for region in self.regions if region.start[0] == row), + Selection.cursor(self.cursor)) + + def selected_text(self) -> str: + return self.region_text class LineVisualState(VisualState): label = "VISUAL LINE" - def selection(self, lines: tuple[str, ...]) -> Selection: - start, end = sorted((self.anchor, self.cursor)) - return Selection((start[0], 0), (end[0], len(lines[end[0]]))) - class BlockVisualState(VisualState): label = "VISUAL BLOCK" - def selection_for_line(self, lines: tuple[str, ...], row: int) -> Selection: - top, bottom = sorted((self.anchor[0], self.cursor[0])) - left, right = sorted((self.anchor[1], self.cursor[1])) - if top <= row <= bottom: - return Selection((row, left), (row, right + 1)) - return Selection.cursor(self.cursor) + +@dataclass(frozen=True) +class NeovimLineChange: + first: int + last: int + lines: tuple[str, ...] @dataclass(frozen=True) @@ -132,17 +181,17 @@ class NeovimSnapshot: owner: NeovimEditing lines: tuple[str, ...] state: NeovimState + changes: tuple[NeovimLineChange, ...] = () def apply(self, editor: TextArea) -> None: - if editor.editing_mode is not self.owner or self is not self.owner.snapshot: + if (editor.editing_mode is not self.owner or self.owner._receiver() is not editor + or not editor.is_attached or self is not self.owner.snapshot + or self is self.owner._rendered): return - text = "\n".join(self.lines) - if editor.text != text: - # A derived rendering document has no independently writable undo stack. - editor.history.clear() - editor._set_document(text, editor.language) - editor.post_message(editor.Changed(editor)) - editor.update_suggestion() + for change in self.changes: + editor.apply_engine_lines(change.first, change.last, change.lines) + del self.owner._changes[:len(self.changes)] + self.owner._rendered = self editor.selection = Selection.cursor(self.state.cursor) editor._line_cache.clear() editor.record_cursor_width() @@ -165,13 +214,15 @@ class NeovimEditing(VimMode): initial_keys = "" def __init__(self) -> None: - self._receiver: ref[TextArea] = lambda: None + self._receiver: Callable[[], TextArea | None] = lambda: None self._pending: dict[int, asyncio.Future[object]] = {} self._request_id = 0 self._lines: tuple[str, ...] = ("",) self.snapshot = NeovimSnapshot(self, self._lines, - NeovimState("NEOVIM STARTING", (0, 0), (0, 0), "")) - self._failure: Exception | None = None + NeovimState("NEOVIM STARTING", (0, 0), "")) + self._failure: EditingFailure | None = None + self._changes: list[NeovimLineChange] = [] + self._rendered: NeovimSnapshot | None = None @property def label(self) -> str: @@ -205,7 +256,7 @@ def _started(self) -> asyncio.Task[None]: editor = self._receiver() if editor is None: raise RuntimeError("Neovim requires an attached editor") - self._initial_text = editor.text + self._initial_lines = tuple(editor.document.lines) self._initial_selection = editor.selection self._child_ready = asyncio.get_running_loop().create_future() self._tail = asyncio.get_running_loop().create_future() @@ -236,9 +287,10 @@ async def unmount(self, editor: TextArea) -> None: await self.close() async def close(self) -> None: - if "_started" not in self.__dict__: - return + started = self._started self._receiver = lambda: None + started.cancel() + self._tail.cancel() self._lifetime.cancel() await join_retirement(asyncio.create_task(self._join())) @@ -253,15 +305,20 @@ async def _run(self) -> None: """The existing child owner handles identity, stderr and process-group teardown.""" try: async with AsyncExitStack() as cleanup: - spawn = asyncio.create_task(AttachedChild.start(("nvim", "--embed", "--clean", "-n"))) + async def acquire_child(): + child = await AttachedChild.start(("nvim", "--embed", "--clean", "-n")) + cleanup.push_async_callback(child.stop) + return child + + spawn = asyncio.create_task(acquire_child()) try: child = await asyncio.shield(spawn) except asyncio.CancelledError: - child = await join_retirement(spawn) - cleanup.push_async_callback(child.stop) + await join_retirement(spawn) raise - cleanup.push_async_callback(child.stop) self._child = child + if self._child_ready.cancelled(): + return self._child_ready.set_result(child) assert child.stdout is not None unpacker = msgpack.Unpacker(raw=False, strict_map_key=False, max_buffer_size=16 * 1024 * 1024) @@ -284,11 +341,16 @@ async def _run(self) -> None: async def _start(self) -> None: await self._child_ready - await self._rpc("nvim_ui_attach", [80, 24, {"ext_linegrid": True, "ext_cmdline": True, "ext_messages": True}]) - self._lines = tuple(self._initial_text.split("\n")) + editor = self._receiver() + width = max(1, editor.wrap_width) if editor is not None else 80 + height = max(3, editor.content_size.height + 2) if editor is not None else 24 + await self._rpc("nvim_ui_attach", [width, height, {"ext_linegrid": True, "ext_cmdline": True, "ext_messages": True}]) + self._lines = self._initial_lines + undo_levels = await self._rpc("nvim_get_option_value", ["undolevels", {"buf": 0}]) + await self._rpc("nvim_set_option_value", ["undolevels", -1, {"buf": 0}]) await self._rpc("nvim_buf_set_lines", [0, 0, -1, True, list(self._lines)]) + await self._rpc("nvim_set_option_value", ["undolevels", undo_levels, {"buf": 0}]) await self._rpc("nvim_win_set_cursor", [0, self._byte_cursor(self._initial_selection.end)]) - await self._rpc("nvim_buf_attach", [0, True, {}]) script = files("toad").joinpath("neovim_editing.lua").read_text() editor = self._receiver() mappings: list[list[str]] = [] @@ -297,8 +359,14 @@ async def _start(self) -> None: for spelling, native in binding.declared_key.input_bindings(key): mappings.append([self.input_code(spelling), self.input_code(native), native]) await self._rpc("nvim_exec_lua", [script, [mappings]]) + if editor is not None: + await self._rpc("nvim_set_option_value", ["tabstop", editor.indent_width, {}]) if self.initial_keys: - await self._rpc("nvim_input", [self.initial_keys]) + await self._input(self.initial_keys) + # Establish Insert before restoring a cursor at the end of a line; + # Normal correctly clamps that location to its last character. + await self._rpc("nvim_exec_lua", ["return ToadEditor.state()", []]) + await self._rpc("nvim_win_set_cursor", [0, self._byte_cursor(self._initial_selection.end)]) await self._snapshot() async def _rpc(self, method: str, arguments: list[object]) -> object: @@ -339,9 +407,10 @@ async def _receive(self, message: object) -> None: if tick is not None: stop = len(self._lines) if last == -1 else last self._lines = self._lines[:first] + tuple(lines) + self._lines[stop:] + self._changes.append(NeovimLineChange(first, last, tuple(lines))) elif method == "toad_editor_state": state = NeovimState.decode(arguments, self._lines) - self.snapshot = NeovimSnapshot(self, self._lines, state) + self.snapshot = NeovimSnapshot(self, self._lines, state, tuple(self._changes)) if (editor := self._receiver()) is not None and editor.is_attached: editor.post_message(NeovimUpdated(self.snapshot)) # Grid data is deliberately not scraped. Text comes from buffer events. @@ -362,8 +431,8 @@ def _byte_cursor(self, location: tuple[int, int]) -> list[int]: return [row + 1, len(self._lines[row][:column].encode("utf-8"))] async def _snapshot(self) -> None: - raw = await self._rpc("nvim_eval", ["[getpos('.')[1:2],getpos('v')[1:2],mode(1),state(),v:count,getcmdline()]"]) - self.snapshot = NeovimSnapshot(self, self._lines, NeovimState.decode(raw, self._lines)) + raw = await self._rpc("nvim_exec_lua", ["return ToadEditor.state()", []]) + self.snapshot = NeovimSnapshot(self, self._lines, NeovimState.decode(raw, self._lines), tuple(self._changes)) if (editor := self._receiver()) is not None and editor.is_attached: editor.post_message(NeovimUpdated(self.snapshot)) @@ -374,7 +443,6 @@ async def run() -> None: await self._started await previous await operation() - await self._snapshot() task = asyncio.create_task(run()) self._tail = task @@ -387,53 +455,95 @@ def _observe_failure(self, task: asyncio.Future[None]) -> None: def _fail(self, error: Exception) -> None: if self._failure is None: - self._failure = error + self._failure = EditingFailure(str(error)) if (editor := self._receiver()) is not None: editor.notify(f"{error}. The displayed draft is preserved. Input was not replayed.", title="Neovim editing failed", severity="error", timeout=0) editor.show_editing_mode(self) - async def flush(self, editor: TextArea) -> None: + async def finish(self, editor: TextArea) -> None: + """An explicit profile handoff cancels pending input in the engine itself.""" await self._started - await self._tail - if self._failure is not None: - raise self._failure - self.snapshot.apply(editor) + await self._input("") + await self.flush(editor) + + async def flush(self, editor: TextArea) -> None: + """An explicit acknowledged barrier, never part of the fast input tail.""" + try: + await self._started + await self._tail + if self._failure is not None: + raise self._failure + await self._snapshot() + self.snapshot.apply(editor) + except Exception as error: + self._fail(error) + assert self._failure is not None + raise self._failure from error - async def prepare_input(self, editor: TextArea) -> None: + async def prepare_input(self, editor: TextArea) -> bool: finished = asyncio.get_running_loop().create_future() async def ready() -> None: + if editor.editing_mode is not self: + finished.set_result(True) + return try: - await self.flush(editor) + await self._started + # This API is fast even while an operand or command-line input + # is pending. No deferred eval can hold the next key hostage. + current = await self._rpc("nvim_get_mode", []) + state = NeovimState.fast_mode(current, self.snapshot.state) + self.snapshot = NeovimSnapshot(self, self._lines, state, tuple(self._changes)) + self.snapshot.apply(editor) except Exception as error: - finished.set_exception(error) + self._fail(error) + finished.set_result(False) else: - finished.set_result(None) + finished.set_result(True) - editor.call_later(ready) - await finished + if not editor.call_later(ready): + return False # The original receiver retired before this input. + return await finished async def handle_key(self, editor: TextArea, event: Key) -> bool: - await self._enqueue(lambda: self._rpc("nvim_input", [self.input_code(event.key, event.character)])) - self.snapshot.apply(editor) + try: + await self._started + await self._input(self.input_code(event.key, event.character)) + except Exception as error: + self._fail(error) return True + async def _input(self, keys: str) -> object: + accepted = await self._rpc("nvim_input", [keys]) + if accepted != len(keys.encode("utf-8")): + raise RuntimeError("Neovim did not accept the complete key; it was not replayed") + return accepted + @staticmethod def input_code(key: str, character: str | None = None) -> str: """Textual's external key vocabulary enters Neovim at this one boundary.""" - if character is not None and character.isprintable(): - return character.replace("<", "") - if len(key) == 1: - return key.replace("<", "") *modifiers, name = key.split("+") + literal = character or key_to_character(name) + if not modifiers and literal is not None and literal.isprintable(): + return literal.replace("<", "") names = {"enter": "CR", "escape": "Esc", "backspace": "BS", "delete": "Del", "pageup": "PageUp", "pagedown": "PageDown", "space": "Space"} prefixes = {"ctrl": "C", "shift": "S", "alt": "A", "meta": "M", "super": "D"} - return "<" + "-".join([*(prefixes[part] for part in modifiers), names.get(name, name)]) + ">" + base = literal if literal is not None and literal.isprintable() else names.get(name, name) + base = "LT" if base == "<" else base + return "<" + "-".join([*(prefixes[part] for part in modifiers), base]) + ">" + + def resize(self, editor: TextArea) -> None: + self._enqueue(lambda: self._rpc("nvim_ui_try_resize", + [max(1, editor.wrap_width), max(3, editor.content_size.height + 2)])) def enter_text(self, editor: TextArea) -> None: - self._enqueue(lambda: self._rpc("nvim_input", ["i"])) + async def enter() -> None: + await self._started + await self._input("i") + task = asyncio.create_task(enter()) + task.add_done_callback(self._observe_failure) def load_text(self, editor: TextArea, text: str) -> None: self._enqueue(lambda: self._rpc("nvim_exec_lua", ["return ToadEditor.load(...)", [text]])) @@ -447,6 +557,16 @@ async def replace() -> object: edit.text.split("\n"), edit.maintain_selection_offset]]) self._enqueue(replace) + def insert(self, editor: TextArea, text: str, location: Location | None, maintain_selection_offset: bool) -> None: + async def insert() -> object: + position = None if location is None else self._byte_cursor(location) + return await self._rpc("nvim_exec_lua", ["return ToadEditor.insert(...)", + [text.split("\n"), position, maintain_selection_offset]]) + self._enqueue(insert) + + def clear(self, editor: TextArea) -> None: + self.load_text(editor, "") + def paste(self, editor: TextArea, text: str) -> None: self._enqueue(lambda: self._rpc("nvim_paste", [text, True, -1])) @@ -465,3 +585,6 @@ def selection_range(self, editor: TextArea) -> Selection: def selection_for_line(self, editor: TextArea, row: int) -> Selection: return self.snapshot.state.selection_for_line(self.snapshot.lines, row) + + def selected_text(self, editor: TextArea) -> str: + return self.snapshot.state.selected_text() diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 269ae48fa..b835abc0b 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -19,6 +19,7 @@ from textual.binding import ModeBinding from textual.widgets.text_area import EditingMode, VimMode from toad.neovim_editing import NeovimUpdated +from textual.document._editing import EditingFailure from textual.content import Content, Span from textual.css.scalar import Scalar from textual.geometry import Spacing @@ -384,8 +385,14 @@ async def action_tab_complete(self) -> None: ) async def _submit(self) -> None: - await self.editing_mode.flush(self) self._submit_pending = False + mode = self.editing_mode + try: + await mode.flush(self) + except EditingFailure: + return # The engine already reported its failure; never submit stale text. + if mode is not self.editing_mode: + return immediate, self._submit_immediate = self._submit_immediate, False if not self.has_focus or not self.submission_allowed: return From 16b61665b989198f342f13d917f522c0a1692f79 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:11:47 -0400 Subject: [PATCH 03/27] Preserve engine cursor authority and fence draft submission and transfer --- src/toad/neovim_editing.lua | 22 +++++++++++++++-- src/toad/neovim_editing.py | 48 ++++++++++++++++++++++++++++--------- src/toad/widgets/prompt.py | 11 ++++----- uv.lock | 4 +++- 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua index 6d9372f09..34671e5df 100644 --- a/src/toad/neovim_editing.lua +++ b/src/toad/neovim_editing.lua @@ -74,8 +74,26 @@ end ToadEditor = {state = state} function ToadEditor.load(text) api.nvim_buf_set_lines(0, 0, -1, true, vim.split(text, '\n', {plain = true})) - local lines = api.nvim_buf_get_lines(0, 0, -1, true) - api.nvim_win_set_cursor(0, {#lines, #lines[#lines]}) + api.nvim_win_set_cursor(0, {1, 0}) +end +function ToadEditor.cursor_end() + local count = api.nvim_buf_line_count(0) + local line = api.nvim_buf_get_lines(0, count - 1, count, true)[1] + api.nvim_win_set_cursor(0, {count, #line}) +end +function ToadEditor.select(first, last) + -- TextArea API ranges are end-exclusive; Neovim owns the actual Visual state. + local row, byte = last[1], last[2] + if byte == 0 and row > 1 then + row = row - 1 + byte = #api.nvim_buf_get_lines(0, row - 1, row, true)[1] + end + local line = api.nvim_buf_get_lines(0, row - 1, row, true)[1] + local characters = vim.str_utfindex(line, 'utf-32', byte, false) + byte = vim.str_byteindex(line, 'utf-32', math.max(0, characters - 1), false) + api.nvim_win_set_cursor(0, first) + vim.cmd.normal({args = {'v'}, bang = true}) + api.nvim_win_set_cursor(0, {row, byte}) end function ToadEditor.insert(lines, position, maintain_offset) position = position or api.nvim_win_get_cursor(0) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index a10d67e2a..543bdd197 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -192,7 +192,7 @@ def apply(self, editor: TextArea) -> None: editor.apply_engine_lines(change.first, change.last, change.lines) del self.owner._changes[:len(self.changes)] self.owner._rendered = self - editor.selection = Selection.cursor(self.state.cursor) + editor.apply_engine_selection(Selection.cursor(self.state.cursor)) editor._line_cache.clear() editor.record_cursor_width() editor.show_editing_mode(self.owner) @@ -265,7 +265,11 @@ def _started(self) -> asyncio.Task[None]: return asyncio.create_task(self._start()) def attach(self, editor: TextArea) -> None: + previous = self._receiver() self._receiver = ref(editor) + if previous is not editor: + self._rendered = None + self.snapshot = NeovimSnapshot(self, self._lines, self.snapshot.state, tuple(self._changes)) # Starting is lazy: Standard never starts a Neovim process. started = self._started started.add_done_callback(self._observe_failure) @@ -379,6 +383,10 @@ async def _rpc(self, method: str, arguments: list[object]) -> object: await self._child.write(msgpack.packb([0, identity, method, arguments], use_bin_type=True)) async with asyncio.timeout(5): return await future + except Exception as error: + self._fail(error) + assert self._failure is not None + raise self._failure from error finally: self._pending.pop(identity, None) @@ -463,7 +471,7 @@ def _fail(self, error: Exception) -> None: async def finish(self, editor: TextArea) -> None: """An explicit profile handoff cancels pending input in the engine itself.""" - await self._started + await self.acknowledge(editor) await self._input("") await self.flush(editor) @@ -481,6 +489,19 @@ async def flush(self, editor: TextArea) -> None: assert self._failure is not None raise self._failure from error + async def acknowledge(self, editor: TextArea) -> None: + try: + await self._started + # Fast even when an operand, input() or command-line prompt is pending. + current = await self._rpc("nvim_get_mode", []) + state = NeovimState.fast_mode(current, self.snapshot.state) + self.snapshot = NeovimSnapshot(self, self._lines, state, tuple(self._changes)) + self.snapshot.apply(editor) + except Exception as error: + self._fail(error) + assert self._failure is not None + raise self._failure from error + async def prepare_input(self, editor: TextArea) -> bool: finished = asyncio.get_running_loop().create_future() @@ -489,13 +510,7 @@ async def ready() -> None: finished.set_result(True) return try: - await self._started - # This API is fast even while an operand or command-line input - # is pending. No deferred eval can hold the next key hostage. - current = await self._rpc("nvim_get_mode", []) - state = NeovimState.fast_mode(current, self.snapshot.state) - self.snapshot = NeovimSnapshot(self, self._lines, state, tuple(self._changes)) - self.snapshot.apply(editor) + await self.acknowledge(editor) except Exception as error: self._fail(error) finished.set_result(False) @@ -567,6 +582,9 @@ async def insert() -> object: def clear(self, editor: TextArea) -> None: self.load_text(editor, "") + def move_to_end(self, editor: TextArea) -> None: + self._enqueue(lambda: self._rpc("nvim_exec_lua", ["return ToadEditor.cursor_end()", []])) + def paste(self, editor: TextArea, text: str) -> None: self._enqueue(lambda: self._rpc("nvim_paste", [text, True, -1])) @@ -576,9 +594,17 @@ def undo(self, editor: TextArea) -> None: def redo(self, editor: TextArea) -> None: self._enqueue(lambda: self._rpc("nvim_command", ["redo"])) - def selection_changed(self, editor: TextArea, selection: Selection) -> None: - if selection.end != self.snapshot.state.cursor: + def request_selection(self, editor: TextArea, selection: Selection) -> Selection: + if not selection.is_empty: + async def select() -> object: + await self._input("") + first, last = sorted(selection) + return await self._rpc("nvim_exec_lua", ["return ToadEditor.select(...)", + [self._byte_cursor(first), self._byte_cursor(last)]]) + self._enqueue(select) + elif selection.end != self.snapshot.state.cursor: self._enqueue(lambda: self._rpc("nvim_win_set_cursor", [0, self._byte_cursor(selection.end)])) + return editor.selection def selection_range(self, editor: TextArea) -> Selection: return self.snapshot.state.selection(self.snapshot.lines) diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index b835abc0b..e0b98d639 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -388,6 +388,9 @@ async def _submit(self) -> None: self._submit_pending = False mode = self.editing_mode try: + await mode.acknowledge(self) + if not self.submission_allowed: + return await mode.flush(self) except EditingFailure: return # The engine already reported its failure; never submit stale text. @@ -553,13 +556,9 @@ def text(self) -> str: @text.setter def text(self, text: str) -> None: - self.prompt_text_area.text = text - self.prompt_text_area.call_later(self._finish_text_load) - - async def _finish_text_load(self) -> None: editor = self.prompt_text_area - await editor.editing_mode.flush(editor) - editor.move_cursor(editor.document.end) + editor.text = text + editor.editing_mode.move_to_end(editor) @property def supports_completion(self) -> bool: diff --git a/uv.lock b/uv.lock index eb50833df..521731262 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14.8, <3.15" [[package]] @@ -234,6 +234,7 @@ dependencies = [ { name = "httpx" }, { name = "jeepney", marker = "sys_platform == 'linux'" }, { name = "metaclass-registry" }, + { name = "msgpack" }, { name = "notify-py" }, { name = "objectstate" }, { name = "packaging" }, @@ -279,6 +280,7 @@ requires-dist = [ { name = "httpx", specifier = "==0.28.1" }, { name = "jeepney", marker = "sys_platform == 'linux'", specifier = "==0.9.0" }, { name = "metaclass-registry", git = "https://github.com/OpenHCSDev/metaclass-registry?branch=main" }, + { name = "msgpack", specifier = "==1.2.2" }, { name = "notify-py", specifier = "==0.3.43" }, { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400" }, { name = "packaging", specifier = "==26.2" }, From 1209c25a399ff54005758f43491769396e602b34 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:42:43 -0400 Subject: [PATCH 04/27] Admit Neovim keys and edits together without timing out pending Vim input --- src/toad/neovim_editing.lua | 39 +- src/toad/neovim_editing.py | 624 +++++++++++++--------- src/toad/prompt_cursor.py | 19 +- src/toad/session_presentation.py | 14 +- src/toad/widgets/prompt.py | 12 +- tests/textual/test_active_mode_keymap.py | 56 -- tests/textual/test_keymap_screen.py | 25 +- tests/textual/test_prompt_enter_insert.py | 16 +- 8 files changed, 432 insertions(+), 373 deletions(-) delete mode 100644 tests/textual/test_active_mode_keymap.py diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua index 34671e5df..06a051f09 100644 --- a/src/toad/neovim_editing.lua +++ b/src/toad/neovim_editing.lua @@ -24,7 +24,9 @@ local function state() selected = table.concat(vim.fn.getregion(anchor, cursor, {type = kind}), '\n') if kind == 'V' then selected = selected .. '\n' end end - return {{cursor[2], cursor[3]}, code, vim.fn.state(), vim.v.count, + -- Mode/blocking are read by the host through the fast nvim_get_mode API. + -- Running a Lua callback is not evidence that a pending operand has finished. + return {{cursor[2], cursor[3]}, vim.fn.getcmdtype() .. vim.fn.getcmdline(), regions, selected} end local function publish() @@ -60,11 +62,11 @@ vim.on_key(function() vim.schedule(publish) end, namespace) -- Install the actual native arrow declarations, including saved aliases, inside -- Neovim. Its mapping engine knows that the k in fk is an operand, not Up. for _, mapping in ipairs(mappings) do - local lhs, rhs, native = unpack(mapping) + local lhs, rhs, boundary = unpack(mapping) for _, mode in ipairs({'', 'i'}) do vim.keymap.set(mode, lhs, function() if vim.v.count == 0 and not vim.fn.mode(1):match('^no') then - if vim.rpcrequest(1, 'toad_cursor_boundary', native) then return '' end + if vim.rpcrequest(1, 'toad_cursor_boundary', boundary) then return '' end end return api.nvim_replace_termcodes(rhs, true, false, true) end, {expr = true, noremap = true, silent = true}) @@ -81,8 +83,20 @@ function ToadEditor.cursor_end() local line = api.nvim_buf_get_lines(0, count - 1, count, true)[1] api.nvim_win_set_cursor(0, {count, #line}) end +local function byte_position(location) + -- Convert application codepoint coordinates against the actual buffer now, + -- not against a potentially older Python rendering when the edit was queued. + local row = math.max(0, math.min(location[1], api.nvim_buf_line_count(0) - 1)) + local line = api.nvim_buf_get_lines(0, row, row + 1, true)[1] + local column = math.max(0, math.min(location[2], vim.str_utfindex(line, 'utf-32'))) + return {row + 1, vim.str_byteindex(line, 'utf-32', column, false)} +end +function ToadEditor.cursor(location) + api.nvim_win_set_cursor(0, byte_position(location)) +end function ToadEditor.select(first, last) -- TextArea API ranges are end-exclusive; Neovim owns the actual Visual state. + first, last = byte_position(first), byte_position(last) local row, byte = last[1], last[2] if byte == 0 and row > 1 then row = row - 1 @@ -96,21 +110,28 @@ function ToadEditor.select(first, last) api.nvim_win_set_cursor(0, {row, byte}) end function ToadEditor.insert(lines, position, maintain_offset) - position = position or api.nvim_win_get_cursor(0) - return ToadEditor.replace(position[1] - 1, position[2], position[1] - 1, - position[2], lines, maintain_offset) + -- MessagePack nil in an argument array enters Lua as vim.NIL. + if position == vim.NIL then + local cursor = api.nvim_win_get_cursor(0) + local line = api.nvim_get_current_line() + position = {cursor[1] - 1, vim.str_utfindex(line, 'utf-32', cursor[2], false)} + end + return ToadEditor.replace(position, position, lines, maintain_offset) end -function ToadEditor.replace(top, top_byte, bottom, bottom_byte, lines, maintain_offset) +function ToadEditor.replace(first, last, lines, maintain_offset) + local top, bottom = byte_position(first), byte_position(last) + local top_row, top_byte = top[1] - 1, top[2] + local bottom_row, bottom_byte = bottom[1] - 1, bottom[2] local cursor = api.nvim_win_get_cursor(0) local mark = api.nvim_buf_set_extmark(0, namespace, cursor[1] - 1, cursor[2], {right_gravity = true}) - api.nvim_buf_set_text(0, top, top_byte, bottom, bottom_byte, lines) + api.nvim_buf_set_text(0, top_row, top_byte, bottom_row, bottom_byte, lines) local retained = api.nvim_buf_get_extmark_by_id(0, namespace, mark, {}) api.nvim_buf_del_extmark(0, namespace, mark) if maintain_offset then api.nvim_win_set_cursor(0, {retained[1] + 1, retained[2]}) else - local row = top + #lines - 1 + local row = top_row + #lines - 1 local column = #lines[#lines] + (#lines == 1 and top_byte or 0) api.nvim_win_set_cursor(0, {row + 1, column}) end diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 543bdd197..c895e1f86 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -2,21 +2,24 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from abc import ABC, abstractmethod +from collections.abc import Callable, Sequence from contextlib import AsyncExitStack from dataclasses import dataclass from functools import cached_property from importlib.resources import files -from typing import TYPE_CHECKING +from itertools import count +from typing import TYPE_CHECKING, ClassVar from weakref import ref import msgpack from agent_comms.child_process import AttachedChild, join_retirement -from textual.cursor_shape import BlinkingBar, SteadyBlock +from textual.binding import Binding +from textual.cursor_shape import BlinkingBar, CursorShape, SteadyBlock from textual.document._document import Location, Selection -from textual.document._editing import EditingFailure, VimMode -from textual.message import Message +from textual.document._editing import EditingFailure, EditingMode, VimMode from textual.keys import key_to_character +from textual.message import Message if TYPE_CHECKING: from textual.document._edit import Edit @@ -24,72 +27,87 @@ from textual.widgets import TextArea +class NeovimWireFailure(EditingFailure): + """A child, transport or external Neovim protocol failure, not a Vim prefix.""" + + @dataclass(frozen=True) class NeovimState: - """Decoded external state, never inferred from the last key.""" + """Mode and blocking come only from the actual fast nvim_get_mode response.""" code: str + blocking: bool cursor: tuple[int, int] command_line: str regions: tuple[Selection, ...] = () region_text: str = "" - accepts_text = False - allows_application_commands = False - allows_history_navigation = False - cursor_shape = SteadyBlock() + mode_name: ClassVar[str] = "" + cursor_shape: ClassVar[CursorShape] = SteadyBlock() @property def label(self) -> str: - return self.code.upper() + return (self.mode_name or self.code.upper()) + (" (PENDING)" if self.blocking else "") + + @property + def accepts_text(self) -> bool: + return False + + @property + def allows_application_commands(self) -> bool: + return False + + @property + def allows_history_navigation(self) -> bool: + return False @classmethod - def decode(cls, value: object, lines: tuple[str, ...]) -> NeovimState: - if not isinstance(value, list) or len(value) != 7: - raise ValueError("Invalid Neovim editor state") - cursor, code, state, count, command_line, regions, selected = value - if not (isinstance(code, str) and isinstance(state, str) - and isinstance(count, int) and isinstance(command_line, str)): - raise ValueError("Invalid Neovim mode state") + def decode(cls, value: object, lines: tuple[str, ...], previous: NeovimState) -> NeovimState: + if not isinstance(value, list) or len(value) != 4: + raise NeovimWireFailure("Invalid Neovim editor state") + cursor, command_line, regions, selected = value + if not isinstance(command_line, str): + raise NeovimWireFailure("Invalid Neovim command line") def location(position: object) -> tuple[int, int]: if not (isinstance(position, list) and len(position) == 2 and all(isinstance(n, int) for n in position)): - raise ValueError("Invalid Neovim byte position") + raise NeovimWireFailure("Invalid Neovim byte position") row, column = position row = max(0, min(row - 1, len(lines) - 1)) - return row, len(lines[row].encode("utf-8")[:max(0, column - 1)].decode("utf-8")) + try: + return row, len(lines[row].encode("utf-8")[:max(0, column - 1)].decode("utf-8")) + except UnicodeDecodeError as error: + raise NeovimWireFailure("Neovim supplied a split UTF-8 position") from error if not isinstance(regions, list) or not isinstance(selected, str): - raise ValueError("Invalid Neovim selected region") + raise NeovimWireFailure("Invalid Neovim selected region") selections: list[Selection] = [] for region in regions: if not (isinstance(region, list) and len(region) == 2 and all(isinstance(pos, list) and len(pos) == 4 for pos in region)): - raise ValueError("Invalid Neovim region position") + raise NeovimWireFailure("Invalid Neovim region position") start, end = region - first = location(start[1:3]) - final = location(end[1:3]) + first, final = location(start[1:3]), location(end[1:3]) selections.append(Selection(first, (final[0], final[1] + 1))) - return cls.from_fields(code, state, count, location(cursor), command_line, + return cls.from_fields(previous.code, previous.blocking, location(cursor), command_line, tuple(selections), selected) @classmethod def fast_mode(cls, value: object, previous: NeovimState) -> NeovimState: if not isinstance(value, dict) or not isinstance(value.get("mode"), str) or not isinstance(value.get("blocking"), bool): - raise ValueError("Invalid Neovim fast mode response") - return cls.from_fields(value["mode"], "S" if value["blocking"] else "", 0, - previous.cursor, previous.command_line, previous.regions, previous.region_text) + raise NeovimWireFailure("Invalid Neovim fast mode response") + return cls.from_fields(value["mode"], value["blocking"], previous.cursor, + previous.command_line, previous.regions, previous.region_text) @classmethod - def from_fields(cls, code: str, state: str, count: int, cursor: tuple[int, int], + def from_fields(cls, code: str, blocking: bool, cursor: tuple[int, int], command_line: str, regions: tuple[Selection, ...], selected: str) -> NeovimState: - args = code, cursor, command_line, regions, selected - # This is the external Neovim taxonomy boundary, not a key interpreter. + args = code, blocking, cursor, command_line, regions, selected + # The external Neovim taxonomy is decoded here, never from input keys. if code.startswith("i"): return InsertState(*args) if code == "n": - return (NormalState if count == 0 and not any(flag in state for flag in "omS") - else PendingNormalState)(*args) + return NormalState(*args) if code.startswith("no"): return OperatorState(*args) if code == "c": @@ -104,56 +122,65 @@ def from_fields(cls, code: str, state: str, count: int, cursor: tuple[int, int], return ReplaceState(*args) return cls(*args) - def selection(self, lines: tuple[str, ...]) -> Selection: + def selection(self) -> Selection: return Selection.cursor(self.cursor) - def selection_for_line(self, lines: tuple[str, ...], row: int) -> Selection: - return self.selection(lines) + def selection_for_line(self, row: int) -> Selection: + return self.selection() def selected_text(self) -> str: return "" -class PendingNormalState(NeovimState): - label = "NORMAL (PENDING)" +class TextEntryState(NeovimState): + cursor_shape = BlinkingBar() + + @property + def accepts_text(self) -> bool: + return not self.blocking -class OperatorState(NeovimState): - label = "OPERATOR PENDING" +class HistoryState(NeovimState): + @property + def allows_history_navigation(self) -> bool: + return not self.blocking -class CommandLineState(NeovimState): - label = "COMMAND" +class ApplicationCommandState(NeovimState): + @property + def allows_application_commands(self) -> bool: + return not self.blocking -class InsertState(NeovimState): - label = "INSERT" - accepts_text = True - allows_history_navigation = True - cursor_shape = BlinkingBar() +class InsertState(TextEntryState, HistoryState): + mode_name = "INSERT" -class NormalState(NeovimState): - label = "NORMAL" - allows_application_commands = True - allows_history_navigation = True +class NormalState(ApplicationCommandState, HistoryState): + mode_name = "NORMAL" -class ReplaceState(NeovimState): - label = "REPLACE" - accepts_text = True - cursor_shape = BlinkingBar() +class ReplaceState(TextEntryState): + mode_name = "REPLACE" + + +class OperatorState(NeovimState): + mode_name = "OPERATOR" + + +class CommandLineState(NeovimState): + mode_name = "COMMAND" class VisualState(NeovimState): - label = "VISUAL" + mode_name = "VISUAL" - def selection(self, lines: tuple[str, ...]) -> Selection: + def selection(self) -> Selection: if not self.regions: - return super().selection(lines) + return super().selection() return Selection(self.regions[0].start, self.regions[-1].end) - def selection_for_line(self, lines: tuple[str, ...], row: int) -> Selection: + def selection_for_line(self, row: int) -> Selection: return next((region for region in self.regions if region.start[0] == row), Selection.cursor(self.cursor)) @@ -162,26 +189,126 @@ def selected_text(self) -> str: class LineVisualState(VisualState): - label = "VISUAL LINE" + mode_name = "VISUAL LINE" class BlockVisualState(VisualState): - label = "VISUAL BLOCK" + mode_name = "VISUAL BLOCK" + + +class NeovimRequest(ABC): + """One admitted wire request and its eventual reply; sending never waits for it.""" + + _identities = count(1) + + def __init__(self) -> None: + self.identity = next(self._identities) + self.reply: asyncio.Future[object] = asyncio.get_running_loop().create_future() + self.sent: asyncio.Future[None] = asyncio.get_running_loop().create_future() + + @property + @abstractmethod + def method(self) -> str: ... + + @property + @abstractmethod + def arguments(self) -> Sequence[object]: ... + + async def send(self, child: AttachedChild) -> None: + try: + await child.write(msgpack.packb( + [0, self.identity, self.method, list(self.arguments)], use_bin_type=True)) + self.sent.set_result(None) + except OSError as error: + raise NeovimWireFailure(str(error)) from error + + def resolve(self, value: object) -> None: + self.reply.set_result(value) + + async def result(self) -> object: + await asyncio.shield(self.sent) + return await self.wait_reply() + + async def wait_reply(self) -> object: + return await asyncio.shield(self.reply) + + +class NeovimFastRequest(NeovimRequest): + async def wait_reply(self) -> object: + # Only a fast API reply has a wire timeout. A deferred operation may + # legitimately await an operand; that is not a broken child. + try: + async with asyncio.timeout(5): + return await super().wait_reply() + except TimeoutError as error: + raise NeovimWireFailure(f"Neovim fast request {self.method} did not reply") from error + + +class NeovimModeRequest(NeovimFastRequest): + method = "nvim_get_mode" + arguments = () + + +class NeovimCall(NeovimRequest): + def __init__(self, method: str, arguments: Sequence[object]) -> None: + super().__init__() + self._method, self._arguments = method, arguments + + @property + def method(self) -> str: + return self._method + + @property + def arguments(self) -> Sequence[object]: + return self._arguments + + +class NeovimInput(NeovimFastRequest): + method = "nvim_input" + + def __init__(self, keys: str) -> None: + super().__init__() + self.keys = keys + + @property + def arguments(self) -> Sequence[object]: + return (self.keys,) + + def resolve(self, value: object) -> None: + if not isinstance(value, int) or value != len(self.keys.encode("utf-8")): + raise NeovimWireFailure("Neovim did not accept the complete key; it was not replayed") + super().resolve(value) + + +class NeovimChange(ABC): + @abstractmethod + def apply(self, editor: TextArea) -> None: ... @dataclass(frozen=True) -class NeovimLineChange: +class NeovimLineChange(NeovimChange): first: int last: int lines: tuple[str, ...] + def apply(self, editor: TextArea) -> None: + editor.apply_engine_lines(self.first, self.last, self.lines) + + +@dataclass(frozen=True) +class NeovimBufferTransfer(NeovimChange): + lines: tuple[str, ...] + + def apply(self, editor: TextArea) -> None: + editor.apply_engine_text("\n".join(self.lines)) + @dataclass(frozen=True) class NeovimSnapshot: owner: NeovimEditing lines: tuple[str, ...] state: NeovimState - changes: tuple[NeovimLineChange, ...] = () + changes: tuple[NeovimChange, ...] = () def apply(self, editor: TextArea) -> None: if (editor.editing_mode is not self.owner or self.owner._receiver() is not editor @@ -189,7 +316,7 @@ def apply(self, editor: TextArea) -> None: or self is self.owner._rendered): return for change in self.changes: - editor.apply_engine_lines(change.first, change.last, change.lines) + change.apply(editor) del self.owner._changes[:len(self.changes)] self.owner._rendered = self editor.apply_engine_selection(Selection.cursor(self.state.cursor)) @@ -201,28 +328,27 @@ def apply(self, editor: TextArea) -> None: class NeovimUpdated(Message): - """One immutable engine result enters the existing widget message loop.""" - def __init__(self, snapshot: NeovimSnapshot) -> None: super().__init__() self.snapshot = snapshot class NeovimEditing(VimMode): - """One lazy embedded child per editor resource, with serialized asynchronous RPC.""" + """One editor resource, one admission queue; Neovim schedules actual execution.""" initial_keys = "" def __init__(self) -> None: self._receiver: Callable[[], TextArea | None] = lambda: None - self._pending: dict[int, asyncio.Future[object]] = {} - self._request_id = 0 + self._pending: dict[int, NeovimRequest] = {} + self._outgoing: asyncio.Queue[NeovimRequest] = asyncio.Queue() self._lines: tuple[str, ...] = ("",) self.snapshot = NeovimSnapshot(self, self._lines, - NeovimState("NEOVIM STARTING", (0, 0), "")) + NeovimState("NEOVIM STARTING", False, (0, 0), "")) self._failure: EditingFailure | None = None - self._changes: list[NeovimLineChange] = [] + self._changes: list[NeovimChange] = [] self._rendered: NeovimSnapshot | None = None + self._boundaries: list[Binding] = [] @property def label(self) -> str: @@ -241,14 +367,14 @@ def allows_application_commands(self) -> bool: @property def allows_history_navigation(self) -> bool: - return self.snapshot.state.allows_history_navigation + return self._failure is None and self.snapshot.state.allows_history_navigation @property - def cursor_shape(self): + def cursor_shape(self) -> CursorShape: return self.snapshot.state.cursor_shape @property - def binding_mode(self): + def binding_mode(self) -> type[EditingMode]: return type(self) @cached_property @@ -258,11 +384,11 @@ def _started(self) -> asyncio.Task[None]: raise RuntimeError("Neovim requires an attached editor") self._initial_lines = tuple(editor.document.lines) self._initial_selection = editor.selection - self._child_ready = asyncio.get_running_loop().create_future() - self._tail = asyncio.get_running_loop().create_future() - self._tail.set_result(None) - self._lifetime = editor.app.run_worker(self._run(), name="Neovim editor", exit_on_error=False) - return asyncio.create_task(self._start()) + self._child_ready: asyncio.Future[AttachedChild] = asyncio.get_running_loop().create_future() + self._lifetime = editor.app.run_worker(self._run(), name="Neovim editor", exit_on_error=True) + task = asyncio.create_task(self._start()) + task.add_done_callback(self._observe_failure) + return task def attach(self, editor: TextArea) -> None: previous = self._receiver() @@ -270,9 +396,7 @@ def attach(self, editor: TextArea) -> None: if previous is not editor: self._rendered = None self.snapshot = NeovimSnapshot(self, self._lines, self.snapshot.state, tuple(self._changes)) - # Starting is lazy: Standard never starts a Neovim process. started = self._started - started.add_done_callback(self._observe_failure) if started.done() and not started.cancelled() and started.exception() is None: self.snapshot.apply(editor) @@ -294,22 +418,20 @@ async def close(self) -> None: started = self._started self._receiver = lambda: None started.cancel() - self._tail.cancel() self._lifetime.cancel() await join_retirement(asyncio.create_task(self._join())) async def _join(self) -> None: - await asyncio.gather(self._started, self._tail, return_exceptions=True) + await asyncio.gather(self._started, return_exceptions=True) try: await self._lifetime.wait() except asyncio.CancelledError: pass async def _run(self) -> None: - """The existing child owner handles identity, stderr and process-group teardown.""" try: async with AsyncExitStack() as cleanup: - async def acquire_child(): + async def acquire_child() -> AttachedChild: child = await AttachedChild.start(("nvim", "--embed", "--clean", "-n")) cleanup.push_async_callback(child.stop) return child @@ -324,186 +446,213 @@ async def acquire_child(): if self._child_ready.cancelled(): return self._child_ready.set_result(child) - assert child.stdout is not None - unpacker = msgpack.Unpacker(raw=False, strict_map_key=False, max_buffer_size=16 * 1024 * 1024) - while chunk := await child.stdout.read(65536): - unpacker.feed(chunk) - for message in unpacker: - await self._receive(message) - raise RuntimeError(f"Neovim exited: {await child.stderr_tail()}") - except asyncio.CancelledError: - raise - except Exception as error: - self._fail(error) + async with asyncio.TaskGroup() as tasks: + tasks.create_task(self._write_admitted()) + await self._read(child) + except* (EditingFailure, OSError, msgpack.UnpackException, msgpack.FormatError, + msgpack.StackError, msgpack.BufferFull, UnicodeDecodeError) as errors: + self._fail(NeovimWireFailure("; ".join(str(error) for error in errors.exceptions))) finally: - error = self._failure or RuntimeError("Neovim editor closed; input was not replayed") + error = self._failure or EditingFailure("Neovim editor closed; input was not replayed") if not self._child_ready.done(): self._child_ready.set_exception(error) - for future in tuple(self._pending.values()): - if not future.done(): - future.set_exception(error) + for request in self._pending.values(): + if not request.sent.done(): + request.sent.set_exception(error) + if not request.reply.done(): + request.reply.set_exception(error) + self._pending.clear() + + async def _read(self, child: AttachedChild) -> None: + assert child.stdout is not None + unpacker = msgpack.Unpacker(raw=False, strict_map_key=False, max_buffer_size=16 * 1024 * 1024) + while chunk := await child.stdout.read(65536): + unpacker.feed(chunk) + for message in unpacker: + await self._receive(message) + raise NeovimWireFailure(f"Neovim exited: {await child.stderr_tail()}") + + async def _write_admitted(self) -> None: + await self._started + while True: + request = await self._outgoing.get() + # A deferred edit can need a later operand key. Await only the write, + # never its response, before admitting the next request to Neovim. + await request.send(self._child) + + def _register(self, request: NeovimRequest) -> None: + request.reply.add_done_callback(self._observe_failure) + request.sent.add_done_callback(self._observe_failure) + if self._failure is not None: + raise self._failure + self._pending[request.identity] = request + + async def _bootstrap(self, request: NeovimRequest) -> object: + """Acquisition owns the child until its UI/buffer integration is installed.""" + self._register(request) + await request.send(self._child) + return await request.result() + + def _admit(self, request: NeovimRequest) -> asyncio.Task[object]: + try: + self._register(request) + except EditingFailure as error: + request.sent.set_exception(error) + request.reply.set_exception(error) + else: + self._outgoing.put_nowait(request) + result = asyncio.create_task(request.result()) + result.add_done_callback(self._observe_failure) + return result + + def _call(self, method: str, arguments: Sequence[object]) -> asyncio.Future[object]: + return self._admit(NeovimCall(method, arguments)) + + def _input(self, keys: str) -> asyncio.Future[object]: + return self._admit(NeovimInput(keys)) async def _start(self) -> None: await self._child_ready editor = self._receiver() width = max(1, editor.wrap_width) if editor is not None else 80 height = max(3, editor.content_size.height + 2) if editor is not None else 24 - await self._rpc("nvim_ui_attach", [width, height, {"ext_linegrid": True, "ext_cmdline": True, "ext_messages": True}]) + await self._bootstrap(NeovimCall("nvim_ui_attach", [width, height, + {"ext_linegrid": True, "ext_cmdline": True, "ext_messages": True}])) self._lines = self._initial_lines - undo_levels = await self._rpc("nvim_get_option_value", ["undolevels", {"buf": 0}]) - await self._rpc("nvim_set_option_value", ["undolevels", -1, {"buf": 0}]) - await self._rpc("nvim_buf_set_lines", [0, 0, -1, True, list(self._lines)]) - await self._rpc("nvim_set_option_value", ["undolevels", undo_levels, {"buf": 0}]) - await self._rpc("nvim_win_set_cursor", [0, self._byte_cursor(self._initial_selection.end)]) + undo_levels = await self._bootstrap(NeovimCall("nvim_get_option_value", ["undolevels", {"buf": 0}])) + await self._bootstrap(NeovimCall("nvim_set_option_value", ["undolevels", -1, {"buf": 0}])) + await self._bootstrap(NeovimCall("nvim_buf_set_lines", [0, 0, -1, True, list(self._lines)])) + await self._bootstrap(NeovimCall("nvim_set_option_value", ["undolevels", undo_levels, {"buf": 0}])) + initial_cursor = self._initial_selection.end + row, column = initial_cursor + await self._bootstrap(NeovimCall("nvim_win_set_cursor", [0, + [row + 1, len(self._lines[row][:column].encode("utf-8"))]])) script = files("toad").joinpath("neovim_editing.lua").read_text() - editor = self._receiver() - mappings: list[list[str]] = [] + mappings: list[list[object]] = [] if editor is not None: for key, binding in editor.binding_map(type(self)): for spelling, native in binding.declared_key.input_bindings(key): - mappings.append([self.input_code(spelling), self.input_code(native), native]) - await self._rpc("nvim_exec_lua", [script, [mappings]]) + index = len(self._boundaries) + self._boundaries.append(binding) + mappings.append([self.input_code(spelling), self.input_code(native), index]) + await self._bootstrap(NeovimCall("nvim_exec_lua", [script, [mappings]])) if editor is not None: - await self._rpc("nvim_set_option_value", ["tabstop", editor.indent_width, {}]) + await self._bootstrap(NeovimCall("nvim_set_option_value", ["tabstop", editor.indent_width, {}])) if self.initial_keys: - await self._input(self.initial_keys) - # Establish Insert before restoring a cursor at the end of a line; - # Normal correctly clamps that location to its last character. - await self._rpc("nvim_exec_lua", ["return ToadEditor.state()", []]) - await self._rpc("nvim_win_set_cursor", [0, self._byte_cursor(self._initial_selection.end)]) - await self._snapshot() - - async def _rpc(self, method: str, arguments: list[object]) -> object: - if self._failure is not None: - raise self._failure - self._request_id += 1 - identity = self._request_id - future = self._pending[identity] = asyncio.get_running_loop().create_future() - try: - await self._child.write(msgpack.packb([0, identity, method, arguments], use_bin_type=True)) - async with asyncio.timeout(5): - return await future - except Exception as error: - self._fail(error) - assert self._failure is not None - raise self._failure from error - finally: - self._pending.pop(identity, None) + await self._bootstrap(NeovimInput(self.initial_keys)) + await self._bootstrap(NeovimCall("nvim_exec_lua", ["return ToadEditor.cursor(...)", [initial_cursor]])) + raw = await self._bootstrap(NeovimCall("nvim_exec_lua", ["return ToadEditor.state()", []])) + self._accept_state(raw) + self._accept_mode(await self._bootstrap(NeovimModeRequest())) async def _receive(self, message: object) -> None: - """Decode MessagePack-RPC once. All type tests below concern foreign wire data.""" + """The only MessagePack-RPC decoder; these tests concern foreign wire data.""" if not isinstance(message, list) or not message or not isinstance(message[0], int): - raise ValueError("Invalid Neovim RPC message") + raise NeovimWireFailure("Invalid Neovim RPC message") if message[0] == 1 and len(message) == 4: _, identity, error, result = message if not isinstance(identity, int) or identity not in self._pending: - raise ValueError("Unexpected Neovim RPC response") - future = self._pending[identity] + raise NeovimWireFailure("Unexpected Neovim RPC response") + request = self._pending[identity] if error is None: - future.set_result(result) + request.resolve(result) else: - future.set_exception(RuntimeError(f"Neovim RPC failed: {error}")) + request.reply.set_exception(NeovimWireFailure(f"Neovim {request.method}: {error}")) + del self._pending[identity] elif message[0] == 2 and len(message) == 3: _, method, arguments = message if method == "nvim_buf_lines_event": if not isinstance(arguments, list) or len(arguments) != 6: - raise ValueError("Invalid Neovim buffer notification") + raise NeovimWireFailure("Invalid Neovim buffer notification") _, tick, first, last, lines, _more = arguments if not (isinstance(first, int) and isinstance(last, int) and isinstance(lines, list) and all(isinstance(line, str) for line in lines)): - raise ValueError("Invalid Neovim line update") + raise NeovimWireFailure("Invalid Neovim line update") if tick is not None: - stop = len(self._lines) if last == -1 else last - self._lines = self._lines[:first] + tuple(lines) + self._lines[stop:] - self._changes.append(NeovimLineChange(first, last, tuple(lines))) + if last == -1: + self._lines = tuple(lines) + self._changes.append(NeovimBufferTransfer(self._lines)) + else: + self._lines = self._lines[:first] + tuple(lines) + self._lines[last:] + self._changes.append(NeovimLineChange(first, last, tuple(lines))) elif method == "toad_editor_state": - state = NeovimState.decode(arguments, self._lines) - self.snapshot = NeovimSnapshot(self, self._lines, state, tuple(self._changes)) - if (editor := self._receiver()) is not None and editor.is_attached: - editor.post_message(NeovimUpdated(self.snapshot)) - # Grid data is deliberately not scraped. Text comes from buffer events. + self._accept_state(arguments) + self._refresh_mode() elif message[0] == 0 and len(message) == 4: _, identity, method, arguments = message - if method != "toad_cursor_boundary" or not isinstance(arguments, list) or len(arguments) != 1 or not isinstance(arguments[0], str): + if (method != "toad_cursor_boundary" or not isinstance(arguments, list) + or len(arguments) != 1 or not isinstance(arguments[0], int) + or not 0 <= arguments[0] < len(self._boundaries)): await self._child.write(msgpack.packb([1, identity, "Unsupported Neovim host request", None])) return editor = self._receiver() - handled = editor is not None and editor.is_attached and editor.before_engine_input(arguments[0]) + handled = (editor is not None and editor.is_attached + and self._boundaries[arguments[0]].engine_boundary(editor)) await self._child.write(msgpack.packb([1, identity, None, handled])) else: - raise ValueError("Invalid Neovim RPC envelope") + raise NeovimWireFailure("Invalid Neovim RPC envelope") - def _byte_cursor(self, location: tuple[int, int]) -> list[int]: - row, column = location - row = max(0, min(row, len(self._lines) - 1)) - return [row + 1, len(self._lines[row][:column].encode("utf-8"))] - - async def _snapshot(self) -> None: - raw = await self._rpc("nvim_exec_lua", ["return ToadEditor.state()", []]) - self.snapshot = NeovimSnapshot(self, self._lines, NeovimState.decode(raw, self._lines), tuple(self._changes)) + def _publish(self, state: NeovimState) -> None: + self.snapshot = NeovimSnapshot(self, self._lines, state, tuple(self._changes)) if (editor := self._receiver()) is not None and editor.is_attached: editor.post_message(NeovimUpdated(self.snapshot)) - def _enqueue(self, operation: Callable[[], Awaitable[object]]) -> asyncio.Task[None]: - previous = self._tail + def _accept_state(self, raw: object) -> None: + self._publish(NeovimState.decode(raw, self._lines, self.snapshot.state)) - async def run() -> None: - await self._started - await previous - await operation() + def _accept_mode(self, raw: object) -> None: + self._publish(NeovimState.fast_mode(raw, self.snapshot.state)) - task = asyncio.create_task(run()) - self._tail = task - task.add_done_callback(self._observe_failure) - return task + def _mode_reply(self, reply: asyncio.Future[object]) -> None: + try: + self._accept_mode(reply.result()) + except asyncio.CancelledError: + pass + except EditingFailure as error: + self._fail(error) + + def _refresh_mode(self) -> asyncio.Future[object]: + reply = self._admit(NeovimModeRequest()) + reply.add_done_callback(self._mode_reply) + return reply - def _observe_failure(self, task: asyncio.Future[None]) -> None: - if not task.cancelled() and (error := task.exception()) is not None: + def _observe_failure[Result](self, result: asyncio.Future[Result]) -> None: + try: + result.result() + except asyncio.CancelledError: + pass + except EditingFailure as error: self._fail(error) - def _fail(self, error: Exception) -> None: + def _fail(self, error: EditingFailure) -> None: if self._failure is None: - self._failure = EditingFailure(str(error)) + self._failure = error if (editor := self._receiver()) is not None: editor.notify(f"{error}. The displayed draft is preserved. Input was not replayed.", title="Neovim editing failed", severity="error", timeout=0) editor.show_editing_mode(self) - async def finish(self, editor: TextArea) -> None: - """An explicit profile handoff cancels pending input in the engine itself.""" - await self.acknowledge(editor) + async def finish(self, editor: TextArea) -> bool: + """Explicit profile handoff asks Neovim itself to cancel pending input.""" await self._input("") - await self.flush(editor) + return await self.flush(editor) - async def flush(self, editor: TextArea) -> None: - """An explicit acknowledged barrier, never part of the fast input tail.""" - try: - await self._started - await self._tail - if self._failure is not None: - raise self._failure - await self._snapshot() - self.snapshot.apply(editor) - except Exception as error: - self._fail(error) - assert self._failure is not None - raise self._failure from error + async def flush(self, editor: TextArea) -> bool: + """Refuse pending input before a deferred read; never time out a Vim prefix.""" + await self.acknowledge(editor) + if self.snapshot.state.blocking: + return False + raw = await self._call("nvim_exec_lua", ["return ToadEditor.state()", []]) + self._accept_state(raw) + self.snapshot.apply(editor) + return True async def acknowledge(self, editor: TextArea) -> None: - try: - await self._started - # Fast even when an operand, input() or command-line prompt is pending. - current = await self._rpc("nvim_get_mode", []) - state = NeovimState.fast_mode(current, self.snapshot.state) - self.snapshot = NeovimSnapshot(self, self._lines, state, tuple(self._changes)) - self.snapshot.apply(editor) - except Exception as error: - self._fail(error) - assert self._failure is not None - raise self._failure from error + await self._refresh_mode() + self.snapshot.apply(editor) async def prepare_input(self, editor: TextArea) -> bool: - finished = asyncio.get_running_loop().create_future() + finished: asyncio.Future[bool] = asyncio.get_running_loop().create_future() async def ready() -> None: if editor.editing_mode is not self: @@ -511,33 +660,25 @@ async def ready() -> None: return try: await self.acknowledge(editor) - except Exception as error: - self._fail(error) + except EditingFailure: finished.set_result(False) else: finished.set_result(True) if not editor.call_later(ready): - return False # The original receiver retired before this input. + return False return await finished async def handle_key(self, editor: TextArea, event: Key) -> bool: try: - await self._started await self._input(self.input_code(event.key, event.character)) - except Exception as error: - self._fail(error) + except EditingFailure: + pass # A real child/wire failure has been reported; never replay input. return True - async def _input(self, keys: str) -> object: - accepted = await self._rpc("nvim_input", [keys]) - if accepted != len(keys.encode("utf-8")): - raise RuntimeError("Neovim did not accept the complete key; it was not replayed") - return accepted - @staticmethod def input_code(key: str, character: str | None = None) -> str: - """Textual's external key vocabulary enters Neovim at this one boundary.""" + """Textual's foreign key vocabulary enters Neovim at this boundary.""" *modifiers, name = key.split("+") literal = character or key_to_character(name) if not modifiers and literal is not None and literal.isprintable(): @@ -550,67 +691,50 @@ def input_code(key: str, character: str | None = None) -> str: return "<" + "-".join([*(prefixes[part] for part in modifiers), base]) + ">" def resize(self, editor: TextArea) -> None: - self._enqueue(lambda: self._rpc("nvim_ui_try_resize", - [max(1, editor.wrap_width), max(3, editor.content_size.height + 2)])) + self._call("nvim_ui_try_resize", [max(1, editor.wrap_width), max(3, editor.content_size.height + 2)]) def enter_text(self, editor: TextArea) -> None: - async def enter() -> None: - await self._started - await self._input("i") - task = asyncio.create_task(enter()) - task.add_done_callback(self._observe_failure) + self._input("i") def load_text(self, editor: TextArea, text: str) -> None: - self._enqueue(lambda: self._rpc("nvim_exec_lua", ["return ToadEditor.load(...)", [text]])) + self._call("nvim_exec_lua", ["return ToadEditor.load(...)", [text]]) def edit(self, editor: TextArea, edit: Edit) -> None: - async def replace() -> object: - top_row, top_byte = self._byte_cursor(edit.top) - bottom_row, bottom_byte = self._byte_cursor(edit.bottom) - return await self._rpc("nvim_exec_lua", ["return ToadEditor.replace(...)", - [top_row - 1, top_byte, bottom_row - 1, bottom_byte, - edit.text.split("\n"), edit.maintain_selection_offset]]) - self._enqueue(replace) + self._call("nvim_exec_lua", ["return ToadEditor.replace(...)", + [edit.top, edit.bottom, edit.text.split("\n"), edit.maintain_selection_offset]]) def insert(self, editor: TextArea, text: str, location: Location | None, maintain_selection_offset: bool) -> None: - async def insert() -> object: - position = None if location is None else self._byte_cursor(location) - return await self._rpc("nvim_exec_lua", ["return ToadEditor.insert(...)", - [text.split("\n"), position, maintain_selection_offset]]) - self._enqueue(insert) + self._call("nvim_exec_lua", ["return ToadEditor.insert(...)", + [text.split("\n"), location, maintain_selection_offset]]) def clear(self, editor: TextArea) -> None: self.load_text(editor, "") def move_to_end(self, editor: TextArea) -> None: - self._enqueue(lambda: self._rpc("nvim_exec_lua", ["return ToadEditor.cursor_end()", []])) + self._call("nvim_exec_lua", ["return ToadEditor.cursor_end()", []]) def paste(self, editor: TextArea, text: str) -> None: - self._enqueue(lambda: self._rpc("nvim_paste", [text, True, -1])) + self._call("nvim_paste", [text, True, -1]) def undo(self, editor: TextArea) -> None: - self._enqueue(lambda: self._rpc("nvim_command", ["undo"])) + self._call("nvim_command", ["undo"]) def redo(self, editor: TextArea) -> None: - self._enqueue(lambda: self._rpc("nvim_command", ["redo"])) + self._call("nvim_command", ["redo"]) def request_selection(self, editor: TextArea, selection: Selection) -> Selection: if not selection.is_empty: - async def select() -> object: - await self._input("") - first, last = sorted(selection) - return await self._rpc("nvim_exec_lua", ["return ToadEditor.select(...)", - [self._byte_cursor(first), self._byte_cursor(last)]]) - self._enqueue(select) + self._input("") + self._call("nvim_exec_lua", ["return ToadEditor.select(...)", sorted(selection)]) elif selection.end != self.snapshot.state.cursor: - self._enqueue(lambda: self._rpc("nvim_win_set_cursor", [0, self._byte_cursor(selection.end)])) + self._call("nvim_exec_lua", ["return ToadEditor.cursor(...)", [selection.end]]) return editor.selection def selection_range(self, editor: TextArea) -> Selection: - return self.snapshot.state.selection(self.snapshot.lines) + return self.snapshot.state.selection() def selection_for_line(self, editor: TextArea, row: int) -> Selection: - return self.snapshot.state.selection_for_line(self.snapshot.lines, row) + return self.snapshot.state.selection_for_line(row) def selected_text(self, editor: TextArea) -> str: return self.snapshot.state.selected_text() diff --git a/src/toad/prompt_cursor.py b/src/toad/prompt_cursor.py index a594a6de0..ec7e535ef 100644 --- a/src/toad/prompt_cursor.py +++ b/src/toad/prompt_cursor.py @@ -3,12 +3,13 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable +from types import FunctionType +from typing import TYPE_CHECKING, ClassVar from weakref import ref from agent_comms.declared_family import DeclaredFamily from textual.content import Content -from textual.keys import Keys +from textual.widgets import TextArea from textual.widgets.text_area import Selection if TYPE_CHECKING: @@ -82,7 +83,7 @@ def retreat(self, editor, movement): class HistoryCursor(DeclaredFamily, affix="Cursor"): direction: int - key: Keys + native_action: ClassVar[FunctionType] @classmethod @abstractmethod @@ -100,19 +101,19 @@ def try_move(cls, editor: PromptTextArea, select: bool, mode: EditingMode) -> bo return False @classmethod - def move(cls, editor: PromptTextArea, select: bool, ordinary: Callable[[bool], object]) -> None: + def move(cls, editor: PromptTextArea, select: bool) -> None: if not cls.try_move(editor, select, editor.editing_mode): - ordinary(select) + cls.native_action(editor, select) @classmethod - def try_input(cls, editor: PromptTextArea, key: str) -> bool: + def try_input(cls, editor: PromptTextArea, action: FunctionType) -> bool: return any(cursor.try_move(editor, False, editor.editing_mode) - for cursor in cls.members_with(cls) if cursor.key.value == key) + for cursor in cls.members_with(cls) if cursor.native_action is action) class PreviousHistoryCursor(HistoryCursor): direction = -1 - key = Keys.Up + native_action = TextArea.action_cursor_up @classmethod def at_edge(cls, editor): @@ -121,7 +122,7 @@ def at_edge(cls, editor): class NextHistoryCursor(HistoryCursor): direction = 1 - key = Keys.Down + native_action = TextArea.action_cursor_down @classmethod def at_edge(cls, editor): diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index cb5b2648f..ab95b86f0 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -62,11 +62,13 @@ def prepare_transcript(self, conversation: Conversation) -> None: conversation.visible_categories = self.visible_categories conversation.window.reader_position = self.reader_position - async def restore(self, conversation: Conversation) -> None: - conversation.input_histories = self.input_histories + async def restore(self, conversation: Conversation) -> bool: editor = conversation.prompt.prompt_text_area - await editor.restore_editor_state(self.editor) + if not await editor.restore_editor_state(self.editor): + return False + conversation.input_histories = self.input_histories editor.shell_mode = self.shell_mode + return True class SessionSurfaceLifetime(ABC): @@ -381,7 +383,8 @@ async def acquire(self, screen: MainScreen): self.state.prepare_transcript(conversation) await self.transcript.bind(conversation).restore_documents(self.transcript) if self.state is not None: - await self.state.restore(conversation) + if not await self.state.restore(conversation): + return self.state = None await self.attach_binding(conversation) await self.transcript.bind(conversation).restore(self.transcript) @@ -403,7 +406,8 @@ async def evict(self) -> None: if (conversation := self.widget) is None: return editor = conversation.prompt.prompt_text_area - await editor.editing_mode.flush(editor) + if not await editor.editing_mode.flush(editor): + return # Retain the actual view/engine while its modal input is pending. self.state = SessionViewState.capture(conversation) await self.remove_native_tree() diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index e0b98d639..aeeafd6d6 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -4,6 +4,7 @@ import shlex from dataclasses import replace from pathlib import Path +from types import FunctionType from agent_comms.repo import Checkout from typing import TYPE_CHECKING, Literal, Self @@ -391,7 +392,8 @@ async def _submit(self) -> None: await mode.acknowledge(self) if not self.submission_allowed: return - await mode.flush(self) + if not await mode.flush(self): + return except EditingFailure: return # The engine already reported its failure; never submit stale text. if mode is not self.editing_mode: @@ -432,14 +434,14 @@ async def _submit(self) -> None: if shell: self.prompt.shell_mode = False - def before_engine_input(self, key: str) -> bool: - return HistoryCursor.try_input(self, key) + def before_engine_input(self, action: FunctionType) -> bool: + return HistoryCursor.try_input(self, action) def action_cursor_up(self, select: bool = False): - PreviousHistoryCursor.move(self, select, super().action_cursor_up) + PreviousHistoryCursor.move(self, select) def action_cursor_down(self, select: bool = False): - NextHistoryCursor.move(self, select, super().action_cursor_down) + NextHistoryCursor.move(self, select) def action_delete_left(self) -> None: if not self.editing_mode.accepts_text: diff --git a/tests/textual/test_active_mode_keymap.py b/tests/textual/test_active_mode_keymap.py deleted file mode 100644 index 49e87116e..000000000 --- a/tests/textual/test_active_mode_keymap.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Application remaps follow the live editor mode and shared sidebar focus.""" - -from textual.document._editing import VimInsert, VimNormal, VimVisual -from textual.widgets import Input - -from toad.application_actions import CommanderAction -from toad.key_modes import VimNormal as NormalProfile -from toad.preferences import SETTINGS -from toad.screens.commander import CommanderPage -from toad.widgets.side_bar import SideBar -from toad.widget_actions import DismissAction - - -async def test_commander_remaps_follow_editor_transitions_and_sidebar_focus(workspace): - async with workspace.app(size=(120, 40)) as ui: - app, _pilot = ui.textual - app.saved_settings.set(SETTINGS.ui.key_mode, NormalProfile) - editor = ui.conversation.prompt.prompt_text_area - remaps = { - CommanderAction.binding_id(VimNormal): "f3", - CommanderAction.binding_id(VimInsert): "f4", - CommanderAction.binding_id(VimVisual): "f5", - } - app.set_keymap(remaps) - for mode, key, other in ((VimNormal(), "f3", "f4"), - (VimInsert(), "f4", "f3"), - (VimVisual(), "f5", "f3")): - editor.editing_mode = mode - editor.focus() - await ui.press(other) - assert app.screen is app.workspace_screen - await ui.press(key) - await ui.until(lambda: isinstance(app.screen, CommanderPage), - why="the commander key for the editor's active mode") - await ui.press("escape") - await ui.until(lambda: app.screen is app.workspace_screen, - why="return from the commander page") - sidebar = app.workspace_chrome.channels - sidebar.reveal() - assert isinstance(sidebar, SideBar) - sidebar.query_one(Input).focus() - await ui.press("f3") - await ui.until(lambda: isinstance(app.screen, CommanderPage), - why="the application binding from shared sidebar focus") - app.set_keymap({**remaps, DismissAction.binding_id(VimNormal): "f6"}) - await ui.press("escape") - assert isinstance(app.screen, CommanderPage) - await ui.press("f6") - await ui.until(lambda: app.screen is app.workspace_screen, - why="the remapped modal dismissal") - await ui.press("f3") - await ui.until(lambda: isinstance(app.screen, CommanderPage), - why="the commander page reopened under the remap") - 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") diff --git a/tests/textual/test_keymap_screen.py b/tests/textual/test_keymap_screen.py index f96983409..5b9572131 100644 --- a/tests/textual/test_keymap_screen.py +++ b/tests/textual/test_keymap_screen.py @@ -5,9 +5,8 @@ from textual.binding import ModeBinding from toad.application_actions import CommanderAction, KeymapAction -from toad.key_modes import KeyMode, Standard, VimInsert +from toad.key_modes import KeyMode, Standard from toad.preferences import SETTINGS, SavedSettings -from textual.document._editing import Escape, VimInsert as InsertEditing, VimNormal from toad.screens.keymap import KeymapScreen from toad.keymap import KeymapRow from toad.widget_actions import ProfileKeys @@ -86,28 +85,6 @@ async def test_f1_menu_remaps_itself_saves_and_refuses_a_conflict(workspace, mod why="the saved menu remap after restarting the app") -async def test_the_menu_remaps_the_native_insert_escape_without_changing_normal(workspace): - async with workspace.app(size=(140, 45)) as ui: - app = ui.app - app.saved_settings.set(SETTINGS.ui.key_mode, VimInsert) - editor = ui.conversation.prompt.prompt_text_area - editor.focus() - await ui.press("f1") - await ui.until(lambda: isinstance(app.screen, KeymapScreen), why="the native command menu") - await choose(ui, Escape(), VimInsert) - app.screen.query_one("#keymap-keys", Input).value = "f6" - await ui.pilot.click("#keymap-apply") - await ui.until(lambda: Escape.binding_id(InsertEditing) in app._keymap, - why="the native Insert escape override") - await ui.press("escape") - await ui.until(lambda: app.screen is app.workspace_screen, why="the menu dismissed") - editor.focus() - await ui.press("escape") - assert isinstance(editor.editing_mode, InsertEditing) - await ui.press("f6") - assert type(editor.editing_mode) is VimNormal - - async def test_the_menu_discovers_and_remaps_a_native_callable_with_both_aliases(workspace): async with workspace.app(size=(140, 45)) as ui: counter = CallCounter("0") diff --git a/tests/textual/test_prompt_enter_insert.py b/tests/textual/test_prompt_enter_insert.py index f4258fe5d..d73c4be36 100644 --- a/tests/textual/test_prompt_enter_insert.py +++ b/tests/textual/test_prompt_enter_insert.py @@ -1,22 +1,8 @@ """Textual-facing: vim `i` asks the prompt for insert mode; the prompt owns whether its editor exists.""" from agent_comms.repo import Checkout -from textual.widgets.text_area import VimInsert - -from support.agents import peer -from toad.session_scope import AgentAttached -from toad.widgets.prompt import MessagePrompt, Prompt +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_enter_insert_sets_insert_mode_and_focuses_the_editor(workspace): - async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: - await ui.until_agent_ready() - app, _pilot = ui.textual - prompt = app.selected_session.query_one(Prompt) - prompt.enter_insert() - await ui.until(lambda: app.focused is prompt.prompt_text_area, why="the editor to take focus") - assert isinstance(prompt.prompt_text_area.editing_mode, VimInsert) From 333b9eede39815889f48bc87724f726d39c2618e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:48:12 -0400 Subject: [PATCH 05/27] Start fast RPC timing at dispatch before transport backpressure --- src/toad/neovim_editing.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index c895e1f86..40f7a398b 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -204,7 +204,7 @@ class NeovimRequest(ABC): def __init__(self) -> None: self.identity = next(self._identities) self.reply: asyncio.Future[object] = asyncio.get_running_loop().create_future() - self.sent: asyncio.Future[None] = asyncio.get_running_loop().create_future() + self.dispatched: asyncio.Future[None] = asyncio.get_running_loop().create_future() @property @abstractmethod @@ -215,10 +215,12 @@ def method(self) -> str: ... def arguments(self) -> Sequence[object]: ... async def send(self, child: AttachedChild) -> None: + # Dispatch starts before transport backpressure. A fast reply can arrive + # during drain(), and must not leave its waiter behind an unset receipt. + self.dispatched.set_result(None) try: await child.write(msgpack.packb( [0, self.identity, self.method, list(self.arguments)], use_bin_type=True)) - self.sent.set_result(None) except OSError as error: raise NeovimWireFailure(str(error)) from error @@ -226,7 +228,7 @@ def resolve(self, value: object) -> None: self.reply.set_result(value) async def result(self) -> object: - await asyncio.shield(self.sent) + await asyncio.shield(self.dispatched) return await self.wait_reply() async def wait_reply(self) -> object: @@ -449,16 +451,15 @@ async def acquire_child() -> AttachedChild: async with asyncio.TaskGroup() as tasks: tasks.create_task(self._write_admitted()) await self._read(child) - except* (EditingFailure, OSError, msgpack.UnpackException, msgpack.FormatError, - msgpack.StackError, msgpack.BufferFull, UnicodeDecodeError) as errors: + except* (EditingFailure, OSError, msgpack.UnpackException, UnicodeDecodeError) as errors: self._fail(NeovimWireFailure("; ".join(str(error) for error in errors.exceptions))) finally: error = self._failure or EditingFailure("Neovim editor closed; input was not replayed") if not self._child_ready.done(): self._child_ready.set_exception(error) for request in self._pending.values(): - if not request.sent.done(): - request.sent.set_exception(error) + if not request.dispatched.done(): + request.dispatched.set_exception(error) if not request.reply.done(): request.reply.set_exception(error) self._pending.clear() @@ -482,7 +483,7 @@ async def _write_admitted(self) -> None: def _register(self, request: NeovimRequest) -> None: request.reply.add_done_callback(self._observe_failure) - request.sent.add_done_callback(self._observe_failure) + request.dispatched.add_done_callback(self._observe_failure) if self._failure is not None: raise self._failure self._pending[request.identity] = request @@ -497,7 +498,7 @@ def _admit(self, request: NeovimRequest) -> asyncio.Task[object]: try: self._register(request) except EditingFailure as error: - request.sent.set_exception(error) + request.dispatched.set_exception(error) request.reply.set_exception(error) else: self._outgoing.put_nowait(request) From e00856934b4cf7d8ae83f709e74a9b16bf1940b5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:25:16 -0400 Subject: [PATCH 06/27] Refuse flush without completed input and keep operand delivery alive --- src/toad/neovim_editing.lua | 76 ++- src/toad/neovim_editing.py | 656 ++++++++++++++--------- src/toad/session_presentation.py | 20 +- src/toad/widgets/conversation.py | 5 + src/toad/widgets/prompt.py | 8 +- tests/textual/test_active_mode_keymap.py | 37 ++ 6 files changed, 512 insertions(+), 290 deletions(-) create mode 100644 tests/textual/test_active_mode_keymap.py diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua index 06a051f09..f520cebd3 100644 --- a/src/toad/neovim_editing.lua +++ b/src/toad/neovim_editing.lua @@ -13,6 +13,16 @@ vim.opt.shortmess:append('I') vim.bo.buftype = 'nofile' vim.bo.bufhidden = 'hide' +-- Admission is not completion. Only SafeState, which excludes typeahead, +-- mappings and unfinished commands, may certify a completed generation. +local generation, completed = 0, -1 +local scheduled = false +local function location(position) + local row = position[2] - 1 + local line = api.nvim_buf_get_lines(0, row, row + 1, true)[1] + local byte = math.max(0, math.min(position[3] - 1, #line)) + return {row, vim.str_utfindex(line, 'utf-32', byte, false)} +end local function state() local cursor, anchor = vim.fn.getpos('.'), vim.fn.getpos('v') local code = vim.fn.mode(1) @@ -20,44 +30,66 @@ local function state() s = 'v', S = 'V', ['\019'] = '\022'})[code] local regions, selected = {}, '' if kind then - regions = vim.fn.getregionpos(anchor, cursor, {type = kind, eol = true}) + for _, region in ipairs(vim.fn.getregionpos(anchor, cursor, {type = kind, eol = true})) do + local first, last = location(region[1]), location(region[2]) + last[2] = last[2] + 1 + regions[#regions + 1] = {start = first, ['end'] = last} + end selected = table.concat(vim.fn.getregion(anchor, cursor, {type = kind}), '\n') if kind == 'V' then selected = selected .. '\n' end end - -- Mode/blocking are read by the host through the fast nvim_get_mode API. - -- Running a Lua callback is not evidence that a pending operand has finished. - return {{cursor[2], cursor[3]}, - vim.fn.getcmdtype() .. vim.fn.getcmdline(), regions, selected} + return {mode = api.nvim_get_mode(), cursor = location(cursor), + command_line = vim.fn.getcmdtype() .. vim.fn.getcmdline(), + regions = regions, selected = selected, completed = completed} end local function publish() - vim.rpcnotify(1, 'toad_editor_state', unpack(state())) + vim.rpcnotify(1, 'toad_editor_state', state()) +end +local function schedule_publish() + if scheduled then return end + scheduled = true + vim.schedule(function() scheduled = false; publish() end) end -- Buffer subscriptions belong to this engine, including :edit/:buffer changes. -- Only its current buffer publishes rendering deltas; returning to a buffer is -- an ownership transfer, not another callback registration. local subscriptions = {} local function entered(buffer) - vim.rpcnotify(1, 'nvim_buf_lines_event', buffer, - api.nvim_buf_get_changedtick(buffer), 0, -1, - api.nvim_buf_get_lines(buffer, 0, -1, true), false) + vim.rpcnotify(1, 'toad_editor_lines', {first = 0, last = -1, + lines = api.nvim_buf_get_lines(buffer, 0, -1, true)}) if subscriptions[buffer] then return end subscriptions[buffer] = true api.nvim_buf_attach(buffer, false, { on_lines = function(_, buf, tick, first, last, new_last) if api.nvim_get_current_buf() == buf then - vim.rpcnotify(1, 'nvim_buf_lines_event', buf, tick, first, last, - api.nvim_buf_get_lines(buf, first, new_last, true), false) + completed = -1 + vim.rpcnotify(1, 'toad_editor_lines', {first = first, last = last, + lines = api.nvim_buf_get_lines(buf, first, new_last, true)}) + schedule_publish() end end, on_detach = function(_, buf) subscriptions[buf] = nil end, }) end -api.nvim_create_autocmd('BufEnter', {callback = function(event) entered(event.buf); publish() end}) +api.nvim_create_autocmd('BufEnter', {callback = function(event) + completed = -1 + entered(event.buf) + schedule_publish() +end}) entered(api.nvim_get_current_buf()) api.nvim_create_autocmd({'ModeChanged', 'CursorMoved', 'CursorMovedI', - 'TextChanged', 'TextChangedI', 'CmdlineChanged', 'SafeState'}, - {callback = publish}) -vim.on_key(function() vim.schedule(publish) end, namespace) + 'TextChanged', 'TextChangedI', 'CmdlineChanged'}, + {callback = schedule_publish}) +api.nvim_create_autocmd('SafeState', {callback = function() + completed = generation + schedule_publish() +end}) +vim.on_key(function() + -- lua.txt: on_key runs BEFORE further processing. This invalidates a proof; + -- it cannot create one, even if the host has already received the input reply. + completed = -1 + schedule_publish() +end, namespace) -- Install the actual native arrow declarations, including saved aliases, inside -- Neovim. Its mapping engine knows that the k in fk is an operand, not Up. @@ -73,7 +105,19 @@ for _, mapping in ipairs(mappings) do end end -ToadEditor = {state = state} +ToadEditor = {} +function ToadEditor.input(serial, keys) + generation = math.max(generation, serial) + completed = -1 + return api.nvim_input(keys) +end +function ToadEditor.call(serial, method, arguments) + generation = math.max(generation, serial) + completed = -1 + local result = api[method](unpack(arguments)) + schedule_publish() + return result +end function ToadEditor.load(text) api.nvim_buf_set_lines(0, 0, -1, true, vim.split(text, '\n', {plain = true})) api.nvim_win_set_cursor(0, {1, 0}) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 40f7a398b..5676fcec5 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -5,26 +5,75 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Sequence from contextlib import AsyncExitStack -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import cached_property from importlib.resources import files from itertools import count -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar from weakref import ref import msgpack from agent_comms.child_process import AttachedChild, join_retirement +from agent_comms.declared_family import DeclaredFamily, UnknownFamilyMember +from agent_comms.field_codec import FieldCodec, MalformedRecord from textual.binding import Binding from textual.cursor_shape import BlinkingBar, CursorShape, SteadyBlock from textual.document._document import Location, Selection from textual.document._editing import EditingFailure, EditingMode, VimMode +from textual.document._edit import Edit, InsertionPoint +from textual.events import Key from textual.keys import key_to_character from textual.message import Message +from textual.widgets import TextArea -if TYPE_CHECKING: - from textual.document._edit import Edit - from textual.events import Key - from textual.widgets import TextArea + +class NeovimKey(DeclaredFamily, affix="Key"): + """Only vocabulary differences are declared; native Neovim key names pass through.""" + + notation: ClassVar[str] + + @classmethod + def spelling(cls, name: str) -> str: + try: + return cls.member_named(name).notation + except UnknownFamilyMember: + return name + + +class EnterKey(NeovimKey): + notation = "CR" + + +class EscapeKey(NeovimKey): + notation = "Esc" + + +class BackspaceKey(NeovimKey): + notation = "BS" + + +class DeleteKey(NeovimKey): + notation = "Del" + + +class CtrlKey(NeovimKey): + notation = "C" + + +class ShiftKey(NeovimKey): + notation = "S" + + +class AltKey(NeovimKey): + notation = "A" + + +class MetaKey(NeovimKey): + notation = "M" + + +class SuperKey(NeovimKey): + notation = "D" class NeovimWireFailure(EditingFailure): @@ -32,21 +81,59 @@ class NeovimWireFailure(EditingFailure): @dataclass(frozen=True) -class NeovimState: - """Mode and blocking come only from the actual fast nvim_get_mode response.""" +class NeovimMode: + """The actual nvim_get_mode answer, decoded once inside a display update.""" - code: str + mode: str blocking: bool - cursor: tuple[int, int] + + +@dataclass(frozen=True) +class NeovimRegion: + start: Location + end: Location + + def selection(self) -> Selection: + return Selection(self.start, self.end) + + +@dataclass(frozen=True) +class NeovimDisplay: + mode: NeovimMode + cursor: Location command_line: str + regions: tuple[NeovimRegion, ...] + selected: str + completed: int + + def state(self) -> NeovimState: + member = next((member for member in NeovimState.members_with(NeovimState) + if member.recognizes(self.mode.mode)), NeovimState) + return member(self.mode.blocking, self.cursor, self.command_line, + tuple(region.selection() for region in self.regions), self.selected) + + +@dataclass(frozen=True) +class NeovimState(DeclaredFamily, affix="State"): + """Behaviour derives from a mode declaration, never from the last input key.""" + + blocking: bool = True + cursor: Location = (0, 0) + command_line: str = "" regions: tuple[Selection, ...] = () region_text: str = "" - mode_name: ClassVar[str] = "" + mode_codes: ClassVar[tuple[str, ...]] = () + mode_prefixes: ClassVar[tuple[str, ...]] = () + mode_name: ClassVar[str] = "NEOVIM" cursor_shape: ClassVar[CursorShape] = SteadyBlock() + @classmethod + def recognizes(cls, code: str) -> bool: + return code in cls.mode_codes or any(code.startswith(prefix) for prefix in cls.mode_prefixes) + @property def label(self) -> str: - return (self.mode_name or self.code.upper()) + (" (PENDING)" if self.blocking else "") + return self.mode_name + (" (PENDING)" if self.blocking else "") @property def accepts_text(self) -> bool: @@ -60,67 +147,26 @@ def allows_application_commands(self) -> bool: def allows_history_navigation(self) -> bool: return False - @classmethod - def decode(cls, value: object, lines: tuple[str, ...], previous: NeovimState) -> NeovimState: - if not isinstance(value, list) or len(value) != 4: - raise NeovimWireFailure("Invalid Neovim editor state") - cursor, command_line, regions, selected = value - if not isinstance(command_line, str): - raise NeovimWireFailure("Invalid Neovim command line") - - def location(position: object) -> tuple[int, int]: - if not (isinstance(position, list) and len(position) == 2 - and all(isinstance(n, int) for n in position)): - raise NeovimWireFailure("Invalid Neovim byte position") - row, column = position - row = max(0, min(row - 1, len(lines) - 1)) - try: - return row, len(lines[row].encode("utf-8")[:max(0, column - 1)].decode("utf-8")) - except UnicodeDecodeError as error: - raise NeovimWireFailure("Neovim supplied a split UTF-8 position") from error - - if not isinstance(regions, list) or not isinstance(selected, str): - raise NeovimWireFailure("Invalid Neovim selected region") - selections: list[Selection] = [] - for region in regions: - if not (isinstance(region, list) and len(region) == 2 - and all(isinstance(pos, list) and len(pos) == 4 for pos in region)): - raise NeovimWireFailure("Invalid Neovim region position") - start, end = region - first, final = location(start[1:3]), location(end[1:3]) - selections.append(Selection(first, (final[0], final[1] + 1))) - return cls.from_fields(previous.code, previous.blocking, location(cursor), command_line, - tuple(selections), selected) + @property + def transferable(self) -> bool: + return False - @classmethod - def fast_mode(cls, value: object, previous: NeovimState) -> NeovimState: - if not isinstance(value, dict) or not isinstance(value.get("mode"), str) or not isinstance(value.get("blocking"), bool): - raise NeovimWireFailure("Invalid Neovim fast mode response") - return cls.from_fields(value["mode"], value["blocking"], previous.cursor, - previous.command_line, previous.regions, previous.region_text) + def update(self, display: NeovimDisplay) -> NeovimState: + return display.state() - @classmethod - def from_fields(cls, code: str, blocking: bool, cursor: tuple[int, int], - command_line: str, regions: tuple[Selection, ...], selected: str) -> NeovimState: - args = code, blocking, cursor, command_line, regions, selected - # The external Neovim taxonomy is decoded here, never from input keys. - if code.startswith("i"): - return InsertState(*args) - if code == "n": - return NormalState(*args) - if code.startswith("no"): - return OperatorState(*args) - if code == "c": - return CommandLineState(*args) - if code in ("v", "s"): - return VisualState(*args) - if code in ("V", "S"): - return LineVisualState(*args) - if code in ("\x16", "\x13"): - return BlockVisualState(*args) - if code.startswith("R"): - return ReplaceState(*args) - return cls(*args) + def fail(self, error: EditingFailure) -> NeovimState: + return FailedState(cursor=self.cursor, command_line=self.command_line, + regions=self.regions, region_text=self.region_text, error=error) + + @property + def binding_answers(self) -> tuple[bool, bool, bool]: + return self.accepts_text, self.allows_application_commands, self.allows_history_navigation + + def raise_failure(self) -> None: + pass + + def close_error(self) -> EditingFailure: + return EditingFailure("Neovim editor closed; input was not replayed") def selection(self) -> Selection: return Selection.cursor(self.cursor) @@ -132,7 +178,13 @@ def selected_text(self) -> str: return "" -class TextEntryState(NeovimState): +class TransferableState(NeovimState, declared_name=None): + @property + def transferable(self) -> bool: + return not self.blocking + + +class TextEntryState(TransferableState, declared_name=None): cursor_shape = BlinkingBar() @property @@ -140,39 +192,74 @@ def accepts_text(self) -> bool: return not self.blocking -class HistoryState(NeovimState): +class HistoryState(NeovimState, declared_name=None): @property def allows_history_navigation(self) -> bool: return not self.blocking -class ApplicationCommandState(NeovimState): +class ApplicationCommandState(TransferableState, declared_name=None): @property def allows_application_commands(self) -> bool: return not self.blocking +class StartingState(NeovimState): + mode_name = "NEOVIM STARTING" + + @property + def label(self) -> str: + return self.mode_name + + +@dataclass(frozen=True) +class FailedState(NeovimState): + error: EditingFailure = field(kw_only=True) + + def update(self, display: NeovimDisplay) -> NeovimState: + return self + + def fail(self, error: EditingFailure) -> NeovimState: + return self + + @property + def label(self) -> str: + return "NEOVIM FAILED — draft preserved" + + def raise_failure(self) -> None: + raise self.error + + def close_error(self) -> EditingFailure: + return self.error + + class InsertState(TextEntryState, HistoryState): + mode_prefixes = ("i",) mode_name = "INSERT" class NormalState(ApplicationCommandState, HistoryState): + mode_codes = ("n",) mode_name = "NORMAL" class ReplaceState(TextEntryState): + mode_prefixes = ("R",) mode_name = "REPLACE" class OperatorState(NeovimState): + mode_prefixes = ("no",) mode_name = "OPERATOR" class CommandLineState(NeovimState): + mode_codes = ("c",) mode_name = "COMMAND" -class VisualState(NeovimState): +class VisualState(TransferableState): + mode_codes = ("v", "s") mode_name = "VISUAL" def selection(self) -> Selection: @@ -189,15 +276,49 @@ def selected_text(self) -> str: class LineVisualState(VisualState): + mode_codes = ("V", "S") mode_name = "VISUAL LINE" class BlockVisualState(VisualState): + mode_codes = ("\x16", "\x13") mode_name = "VISUAL BLOCK" +class DetachedReceiver: + """An engine may outlive its widget; detachment is not child failure.""" + + def require(self) -> TextArea: + raise EditingFailure("Neovim requires an attached editor") + + def owns(self, editor: TextArea) -> bool: + return False + + def visit(self, action: Callable[[TextArea], object]) -> None: + pass + + +class AttachedReceiver(DetachedReceiver): + def __init__(self, editor: TextArea) -> None: + self._editor = ref(editor) + + def require(self) -> TextArea: + editor = self._editor() + if editor is None: + return super().require() + return editor + + def owns(self, editor: TextArea) -> bool: + return self._editor() is editor + + def visit(self, action: Callable[[TextArea], object]) -> None: + editor = self._editor() + if editor is not None and editor.is_attached: + action(editor) + + class NeovimRequest(ABC): - """One admitted wire request and its eventual reply; sending never waits for it.""" + """One admitted request; the writer never waits for its eventual reply.""" _identities = count(1) @@ -215,8 +336,6 @@ def method(self) -> str: ... def arguments(self) -> Sequence[object]: ... async def send(self, child: AttachedChild) -> None: - # Dispatch starts before transport backpressure. A fast reply can arrive - # during drain(), and must not leave its waiter behind an unset receipt. self.dispatched.set_result(None) try: await child.write(msgpack.packb( @@ -237,8 +356,6 @@ async def wait_reply(self) -> object: class NeovimFastRequest(NeovimRequest): async def wait_reply(self) -> object: - # Only a fast API reply has a wire timeout. A deferred operation may - # legitimately await an operand; that is not a broken child. try: async with asyncio.timeout(5): return await super().wait_reply() @@ -246,11 +363,6 @@ async def wait_reply(self) -> object: raise NeovimWireFailure(f"Neovim fast request {self.method} did not reply") from error -class NeovimModeRequest(NeovimFastRequest): - method = "nvim_get_mode" - arguments = () - - class NeovimCall(NeovimRequest): def __init__(self, method: str, arguments: Sequence[object]) -> None: super().__init__() @@ -265,21 +377,30 @@ def arguments(self) -> Sequence[object]: return self._arguments +class NeovimOperation(NeovimCall): + """Programmatic mutation executes on the same engine and invalidates its proof.""" + + def __init__(self, generation: int, method: str, arguments: Sequence[object]) -> None: + super().__init__("nvim_exec_lua", ["return ToadEditor.call(...)", + [generation, method, list(arguments)]]) + + class NeovimInput(NeovimFastRequest): - method = "nvim_input" + method = "nvim__exec_lua_fast" - def __init__(self, keys: str) -> None: + def __init__(self, generation: int, keys: str) -> None: super().__init__() - self.keys = keys + self.generation, self.keys = generation, keys @property def arguments(self) -> Sequence[object]: - return (self.keys,) + return ("return ToadEditor.input(...)", [self.generation, self.keys]) def resolve(self, value: object) -> None: - if not isinstance(value, int) or value != len(self.keys.encode("utf-8")): + accepted = FieldCodec.decode(int, value) + if accepted != len(self.keys.encode("utf-8")): raise NeovimWireFailure("Neovim did not accept the complete key; it was not replayed") - super().resolve(value) + super().resolve(accepted) class NeovimChange(ABC): @@ -307,69 +428,94 @@ def apply(self, editor: TextArea) -> None: @dataclass(frozen=True) class NeovimSnapshot: - owner: NeovimEditing - lines: tuple[str, ...] state: NeovimState - changes: tuple[NeovimChange, ...] = () - - def apply(self, editor: TextArea) -> None: - if (editor.editing_mode is not self.owner or self.owner._receiver() is not editor - or not editor.is_attached or self is not self.owner.snapshot - or self is self.owner._rendered): - return - for change in self.changes: - change.apply(editor) - del self.owner._changes[:len(self.changes)] - self.owner._rendered = self - editor.apply_engine_selection(Selection.cursor(self.state.cursor)) - editor._line_cache.clear() - editor.record_cursor_width() - editor.show_editing_mode(self.owner) - editor.refresh_bindings() - editor.refresh() + completed: int = -1 class NeovimUpdated(Message): - def __init__(self, snapshot: NeovimSnapshot) -> None: + def __init__(self, owner: NeovimEditing) -> None: super().__init__() - self.snapshot = snapshot + self.owner = owner + + +class NeovimNotification(DeclaredFamily, affix="Notification"): + method: ClassVar[str] + + @classmethod + def selection_key(cls) -> str: + return cls.method + + @classmethod + @abstractmethod + def receive(cls, owner: NeovimEditing, arguments: object) -> None: ... + + +class DisplayNotification(NeovimNotification): + method = "toad_editor_state" + + @classmethod + def receive(cls, owner: NeovimEditing, arguments: object) -> None: + display, = FieldCodec.decode(tuple[NeovimDisplay], arguments) + owner.accept_display(display) + + +class LinesNotification(NeovimNotification): + method = "toad_editor_lines" + + @classmethod + def receive(cls, owner: NeovimEditing, arguments: object) -> None: + change, = FieldCodec.decode(tuple[NeovimLineChange], arguments) + owner.accept_lines(change) + + +class RedrawNotification(NeovimNotification): + """The attached UI drives Neovim, but TextArea paints buffer projections.""" + + method = "redraw" + + @classmethod + def receive(cls, owner: NeovimEditing, arguments: object) -> None: + pass class NeovimEditing(VimMode): - """One editor resource, one admission queue; Neovim schedules actual execution.""" + """One engine, one admission order, completed snapshots only from SafeState.""" initial_keys = "" def __init__(self) -> None: - self._receiver: Callable[[], TextArea | None] = lambda: None + self._receiver: DetachedReceiver = DetachedReceiver() self._pending: dict[int, NeovimRequest] = {} self._outgoing: asyncio.Queue[NeovimRequest] = asyncio.Queue() - self._lines: tuple[str, ...] = ("",) - self.snapshot = NeovimSnapshot(self, self._lines, - NeovimState("NEOVIM STARTING", False, (0, 0), "")) - self._failure: EditingFailure | None = None + self._generation = 0 + self.snapshot = NeovimSnapshot(StartingState()) self._changes: list[NeovimChange] = [] - self._rendered: NeovimSnapshot | None = None + self._rendered = self.snapshot + self._update_queued = False self._boundaries: list[Binding] = [] @property def label(self) -> str: - if self._failure is not None: - return "NEOVIM FAILED — draft preserved" state = self.snapshot.state return f"{state.label} {state.command_line}".rstrip() + @property + def completed(self) -> bool: + return self.snapshot.completed == self._generation and not self._pending + @property def accepts_text(self) -> bool: - return self._failure is None and self.snapshot.state.accepts_text + return self.completed and self.snapshot.state.accepts_text @property def allows_application_commands(self) -> bool: - return self._failure is None and self.snapshot.state.allows_application_commands + return self.completed and self.snapshot.state.allows_application_commands @property def allows_history_navigation(self) -> bool: - return self._failure is None and self.snapshot.state.allows_history_navigation + # The engine's expression mapping calls this boundary while an arrow is + # executing. That input cannot already have a SafeState completion. + return self.snapshot.state.allows_history_navigation @property def cursor_shape(self) -> CursorShape: @@ -381,9 +527,7 @@ def binding_mode(self) -> type[EditingMode]: @cached_property def _started(self) -> asyncio.Task[None]: - editor = self._receiver() - if editor is None: - raise RuntimeError("Neovim requires an attached editor") + editor = self._receiver.require() self._initial_lines = tuple(editor.document.lines) self._initial_selection = editor.selection self._child_ready: asyncio.Future[AttachedChild] = asyncio.get_running_loop().create_future() @@ -393,14 +537,11 @@ def _started(self) -> asyncio.Task[None]: return task def attach(self, editor: TextArea) -> None: - previous = self._receiver() - self._receiver = ref(editor) - if previous is not editor: - self._rendered = None - self.snapshot = NeovimSnapshot(self, self._lines, self.snapshot.state, tuple(self._changes)) + changed_receiver = not self._receiver.owns(editor) + self._receiver = AttachedReceiver(editor) started = self._started if started.done() and not started.cancelled() and started.exception() is None: - self.snapshot.apply(editor) + self.render(editor, force=changed_receiver) def resume(self, editor: TextArea) -> None: pass @@ -409,16 +550,16 @@ def suspend(self, editor: TextArea) -> None: pass def capture(self, editor: TextArea) -> NeovimEditing: - self._receiver = lambda: None + self._receiver = DetachedReceiver() return self async def unmount(self, editor: TextArea) -> None: - if self._receiver() is editor: + if self._receiver.owns(editor): await self.close() async def close(self) -> None: started = self._started - self._receiver = lambda: None + self._receiver = DetachedReceiver() started.cancel() self._lifetime.cancel() await join_retirement(asyncio.create_task(self._join())) @@ -451,10 +592,11 @@ async def acquire_child() -> AttachedChild: async with asyncio.TaskGroup() as tasks: tasks.create_task(self._write_admitted()) await self._read(child) - except* (EditingFailure, OSError, msgpack.UnpackException, UnicodeDecodeError) as errors: + except* (EditingFailure, OSError, msgpack.UnpackException, + UnicodeDecodeError, MalformedRecord) as errors: self._fail(NeovimWireFailure("; ".join(str(error) for error in errors.exceptions))) finally: - error = self._failure or EditingFailure("Neovim editor closed; input was not replayed") + error = self.snapshot.state.close_error() if not self._child_ready.done(): self._child_ready.set_exception(error) for request in self._pending.values(): @@ -477,19 +619,15 @@ async def _write_admitted(self) -> None: await self._started while True: request = await self._outgoing.get() - # A deferred edit can need a later operand key. Await only the write, - # never its response, before admitting the next request to Neovim. await request.send(self._child) def _register(self, request: NeovimRequest) -> None: request.reply.add_done_callback(self._observe_failure) request.dispatched.add_done_callback(self._observe_failure) - if self._failure is not None: - raise self._failure + self.snapshot.state.raise_failure() self._pending[request.identity] = request async def _bootstrap(self, request: NeovimRequest) -> object: - """Acquisition owns the child until its UI/buffer integration is installed.""" self._register(request) await request.send(self._child) return await request.result() @@ -506,116 +644,123 @@ def _admit(self, request: NeovimRequest) -> asyncio.Task[object]: result.add_done_callback(self._observe_failure) return result + def _next_generation(self) -> int: + self._generation += 1 + return self._generation + def _call(self, method: str, arguments: Sequence[object]) -> asyncio.Future[object]: - return self._admit(NeovimCall(method, arguments)) + return self._admit(NeovimOperation(self._next_generation(), method, arguments)) def _input(self, keys: str) -> asyncio.Future[object]: - return self._admit(NeovimInput(keys)) + return self._admit(NeovimInput(self._next_generation(), keys)) async def _start(self) -> None: await self._child_ready - editor = self._receiver() - width = max(1, editor.wrap_width) if editor is not None else 80 - height = max(3, editor.content_size.height + 2) if editor is not None else 24 + editor = self._receiver.require() + width, height = max(1, editor.wrap_width), max(3, editor.content_size.height + 2) await self._bootstrap(NeovimCall("nvim_ui_attach", [width, height, {"ext_linegrid": True, "ext_cmdline": True, "ext_messages": True}])) - self._lines = self._initial_lines + initial_lines = self._initial_lines undo_levels = await self._bootstrap(NeovimCall("nvim_get_option_value", ["undolevels", {"buf": 0}])) await self._bootstrap(NeovimCall("nvim_set_option_value", ["undolevels", -1, {"buf": 0}])) - await self._bootstrap(NeovimCall("nvim_buf_set_lines", [0, 0, -1, True, list(self._lines)])) + await self._bootstrap(NeovimCall("nvim_buf_set_lines", [0, 0, -1, True, list(initial_lines)])) await self._bootstrap(NeovimCall("nvim_set_option_value", ["undolevels", undo_levels, {"buf": 0}])) initial_cursor = self._initial_selection.end row, column = initial_cursor await self._bootstrap(NeovimCall("nvim_win_set_cursor", [0, - [row + 1, len(self._lines[row][:column].encode("utf-8"))]])) + [row + 1, len(initial_lines[row][:column].encode("utf-8"))]])) script = files("toad").joinpath("neovim_editing.lua").read_text() mappings: list[list[object]] = [] - if editor is not None: - for key, binding in editor.binding_map(type(self)): - for spelling, native in binding.declared_key.input_bindings(key): - index = len(self._boundaries) - self._boundaries.append(binding) - mappings.append([self.input_code(spelling), self.input_code(native), index]) + for key, binding in editor.binding_map(type(self)): + for spelling, native in binding.declared_key.input_bindings(key): + index = len(self._boundaries) + self._boundaries.append(binding) + mappings.append([self.input_code(spelling), self.input_code(native), index]) await self._bootstrap(NeovimCall("nvim_exec_lua", [script, [mappings]])) - if editor is not None: - await self._bootstrap(NeovimCall("nvim_set_option_value", ["tabstop", editor.indent_width, {}])) + await self._bootstrap(NeovimCall("nvim_set_option_value", ["tabstop", editor.indent_width, {}])) if self.initial_keys: - await self._bootstrap(NeovimInput(self.initial_keys)) + await self._bootstrap(NeovimInput(self._next_generation(), self.initial_keys)) await self._bootstrap(NeovimCall("nvim_exec_lua", ["return ToadEditor.cursor(...)", [initial_cursor]])) - raw = await self._bootstrap(NeovimCall("nvim_exec_lua", ["return ToadEditor.state()", []])) - self._accept_state(raw) - self._accept_mode(await self._bootstrap(NeovimModeRequest())) async def _receive(self, message: object) -> None: - """The only MessagePack-RPC decoder; these tests concern foreign wire data.""" - if not isinstance(message, list) or not message or not isinstance(message[0], int): - raise NeovimWireFailure("Invalid Neovim RPC message") - if message[0] == 1 and len(message) == 4: - _, identity, error, result = message - if not isinstance(identity, int) or identity not in self._pending: - raise NeovimWireFailure("Unexpected Neovim RPC response") - request = self._pending[identity] + """Decode the foreign MessagePack-RPC envelope once at the child boundary.""" + frame = FieldCodec.decode(tuple[object, ...], message) + if not frame: + raise NeovimWireFailure("Empty Neovim RPC message") + kind = FieldCodec.decode(int, frame[0]) + if kind == 1: + _, identity, error, result = FieldCodec.decode(tuple[int, int, object, object], frame) + try: + request = self._pending[identity] + except KeyError as failure: + raise NeovimWireFailure("Unexpected Neovim RPC response") from failure if error is None: request.resolve(result) else: request.reply.set_exception(NeovimWireFailure(f"Neovim {request.method}: {error}")) del self._pending[identity] - elif message[0] == 2 and len(message) == 3: - _, method, arguments = message - if method == "nvim_buf_lines_event": - if not isinstance(arguments, list) or len(arguments) != 6: - raise NeovimWireFailure("Invalid Neovim buffer notification") - _, tick, first, last, lines, _more = arguments - if not (isinstance(first, int) and isinstance(last, int) and isinstance(lines, list) - and all(isinstance(line, str) for line in lines)): - raise NeovimWireFailure("Invalid Neovim line update") - if tick is not None: - if last == -1: - self._lines = tuple(lines) - self._changes.append(NeovimBufferTransfer(self._lines)) - else: - self._lines = self._lines[:first] + tuple(lines) + self._lines[last:] - self._changes.append(NeovimLineChange(first, last, tuple(lines))) - elif method == "toad_editor_state": - self._accept_state(arguments) - self._refresh_mode() - elif message[0] == 0 and len(message) == 4: - _, identity, method, arguments = message - if (method != "toad_cursor_boundary" or not isinstance(arguments, list) - or len(arguments) != 1 or not isinstance(arguments[0], int) - or not 0 <= arguments[0] < len(self._boundaries)): + elif kind == 2: + _, method, arguments = FieldCodec.decode(tuple[int, str, object], frame) + try: + notification = NeovimNotification.member_selected(method) + except UnknownFamilyMember as error: + raise NeovimWireFailure(str(error)) from error + notification.receive(self, arguments) + elif kind == 0: + _, identity, method, arguments = FieldCodec.decode(tuple[int, int, str, object], frame) + if method != "toad_cursor_boundary": await self._child.write(msgpack.packb([1, identity, "Unsupported Neovim host request", None])) return - editor = self._receiver() - handled = (editor is not None and editor.is_attached - and self._boundaries[arguments[0]].engine_boundary(editor)) + boundary, = FieldCodec.decode(tuple[int], arguments) + if not 0 <= boundary < len(self._boundaries): + raise NeovimWireFailure("Unknown Neovim cursor boundary") + handled = False + def offer(editor: TextArea) -> None: + nonlocal handled + handled = self._boundaries[boundary].engine_boundary(editor) + self._receiver.visit(offer) await self._child.write(msgpack.packb([1, identity, None, handled])) else: raise NeovimWireFailure("Invalid Neovim RPC envelope") - def _publish(self, state: NeovimState) -> None: - self.snapshot = NeovimSnapshot(self, self._lines, state, tuple(self._changes)) - if (editor := self._receiver()) is not None and editor.is_attached: - editor.post_message(NeovimUpdated(self.snapshot)) - - def _accept_state(self, raw: object) -> None: - self._publish(NeovimState.decode(raw, self._lines, self.snapshot.state)) + def accept_lines(self, change: NeovimLineChange) -> None: + if change.last == -1: + self._changes.append(NeovimBufferTransfer(change.lines)) + else: + self._changes.append(change) - def _accept_mode(self, raw: object) -> None: - self._publish(NeovimState.fast_mode(raw, self.snapshot.state)) + def accept_display(self, display: NeovimDisplay) -> None: + self._publish(NeovimSnapshot(self.snapshot.state.update(display), display.completed)) - def _mode_reply(self, reply: asyncio.Future[object]) -> None: - try: - self._accept_mode(reply.result()) - except asyncio.CancelledError: - pass - except EditingFailure as error: - self._fail(error) - - def _refresh_mode(self) -> asyncio.Future[object]: - reply = self._admit(NeovimModeRequest()) - reply.add_done_callback(self._mode_reply) - return reply + def _publish(self, snapshot: NeovimSnapshot) -> None: + self.snapshot = snapshot + if not self._update_queued: + def post(editor: TextArea) -> None: + self._update_queued = editor.post_message(NeovimUpdated(self)) + self._receiver.visit(post) + + def render(self, editor: TextArea, *, force: bool = False) -> None: + self._update_queued = False + if editor.editing_mode is not self or not self._receiver.owns(editor): + return + if self.snapshot is self._rendered and not force: + return + previous, current = self._rendered.state, self.snapshot.state + changes, self._changes = self._changes, [] + for change in changes: + change.apply(editor) + if changes: + editor.finish_engine_changes() + if force or previous.cursor != current.cursor: + editor.apply_engine_selection(Selection.cursor(current.cursor)) + editor.record_cursor_width() + if previous.regions != current.regions: + editor.invalidate_engine_regions((*previous.regions, *current.regions)) + if force or previous.label != current.label or previous.command_line != current.command_line: + editor.show_editing_mode(self) + if force or previous.binding_answers != current.binding_answers: + editor.refresh_bindings() + self._rendered = self.snapshot def _observe_failure[Result](self, result: asyncio.Future[Result]) -> None: try: @@ -626,70 +771,53 @@ def _observe_failure[Result](self, result: asyncio.Future[Result]) -> None: self._fail(error) def _fail(self, error: EditingFailure) -> None: - if self._failure is None: - self._failure = error - if (editor := self._receiver()) is not None: - editor.notify(f"{error}. The displayed draft is preserved. Input was not replayed.", - title="Neovim editing failed", severity="error", timeout=0) - editor.show_editing_mode(self) + previous = self.snapshot.state + failed = previous.fail(error) + if failed is previous: + return + self._publish(NeovimSnapshot(failed)) + def notify(editor: TextArea) -> None: + editor.notify(f"{error}. The displayed draft is preserved. Input was not replayed.", + title="Neovim editing failed", severity="error", timeout=0) + self._receiver.visit(notify) async def finish(self, editor: TextArea) -> bool: - """Explicit profile handoff asks Neovim itself to cancel pending input.""" - await self._input("") return await self.flush(editor) async def flush(self, editor: TextArea) -> bool: - """Refuse pending input before a deferred read; never time out a Vim prefix.""" - await self.acknowledge(editor) - if self.snapshot.state.blocking: - return False - raw = await self._call("nvim_exec_lua", ["return ToadEditor.state()", []]) - self._accept_state(raw) - self.snapshot.apply(editor) - return True - - async def acknowledge(self, editor: TextArea) -> None: - await self._refresh_mode() - self.snapshot.apply(editor) + """Never wait for deferred RPC in App dispatch. Missing completion refuses.""" + self.snapshot.state.raise_failure() + self.render(editor) + return self.completed and self.snapshot.state.transferable async def prepare_input(self, editor: TextArea) -> bool: + # Only fence the widget queue, never the Neovim event loop. Escape must + # remain admissible while a deferred operation is waiting for an operand. finished: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - - async def ready() -> None: - if editor.editing_mode is not self: - finished.set_result(True) - return - try: - await self.acknowledge(editor) - except EditingFailure: - finished.set_result(False) - else: - finished.set_result(True) - + def ready() -> None: + self.render(editor) + finished.set_result(True) if not editor.call_later(ready): return False return await finished async def handle_key(self, editor: TextArea, event: Key) -> bool: try: - await self._input(self.input_code(event.key, event.character)) + await self._input(self.input_code(event.key, event.character or "")) except EditingFailure: - pass # A real child/wire failure has been reported; never replay input. + pass # The child/wire failure was reported; never replay input. return True @staticmethod - def input_code(key: str, character: str | None = None) -> str: + def input_code(key: str, character: str = "") -> str: """Textual's foreign key vocabulary enters Neovim at this boundary.""" *modifiers, name = key.split("+") - literal = character or key_to_character(name) - if not modifiers and literal is not None and literal.isprintable(): + literal = character or key_to_character(name) or "" + if not modifiers and literal.isprintable(): return literal.replace("<", "") - names = {"enter": "CR", "escape": "Esc", "backspace": "BS", "delete": "Del", - "pageup": "PageUp", "pagedown": "PageDown", "space": "Space"} - prefixes = {"ctrl": "C", "shift": "S", "alt": "A", "meta": "M", "super": "D"} - base = literal if literal is not None and literal.isprintable() else names.get(name, name) - base = "LT" if base == "<" else base - return "<" + "-".join([*(prefixes[part] for part in modifiers), base]) + ">" + base = literal if literal.isprintable() else NeovimKey.spelling(name) + base = base.replace("<", "LT") + return "<" + "-".join([*(NeovimKey.member_named(part).notation for part in modifiers), base]) + ">" def resize(self, editor: TextArea) -> None: self._call("nvim_ui_try_resize", [max(1, editor.wrap_width), max(3, editor.content_size.height + 2)]) @@ -704,9 +832,9 @@ def edit(self, editor: TextArea, edit: Edit) -> None: self._call("nvim_exec_lua", ["return ToadEditor.replace(...)", [edit.top, edit.bottom, edit.text.split("\n"), edit.maintain_selection_offset]]) - def insert(self, editor: TextArea, text: str, location: Location | None, maintain_selection_offset: bool) -> None: + def insert(self, editor: TextArea, text: str, point: InsertionPoint, maintain_selection_offset: bool) -> None: self._call("nvim_exec_lua", ["return ToadEditor.insert(...)", - [text.split("\n"), location, maintain_selection_offset]]) + [text.split("\n"), point.engine_location(), maintain_selection_offset]]) def clear(self, editor: TextArea) -> None: self.load_text(editor, "") diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 32fffc119..fe003c532 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -11,6 +11,7 @@ from agent_comms.acp_extension import CoordinationChangedUpdate from agent_comms.session_load import EnsuringSessionLoadPlan from agent_comms.ui_model.message_category import MessageCategory +from textual.document._editing import EditingMode from textual.widget import Widget from textual.widgets.text_area import TextAreaState @@ -62,13 +63,11 @@ def prepare_transcript(self, conversation: Conversation) -> None: conversation.visible_categories = self.visible_categories conversation.window.reader_position = self.reader_position - async def restore(self, conversation: Conversation) -> bool: + async def restore(self, conversation: Conversation) -> None: editor = conversation.prompt.prompt_text_area - if not await editor.restore_editor_state(self.editor): - return False + await editor.restore_editor_state(self.editor) conversation.input_histories = self.input_histories editor.shell_mode = self.shell_mode - return True class SessionSurfaceLifetime(ABC): @@ -78,6 +77,10 @@ class SessionSurfaceLifetime(ABC): def operational_binding(self): return NoAgentConnection() + @property + def initial_editing_mode(self) -> EditingMode: + return EditingMode() + @abstractmethod def compose_content(self, screen: MainScreen) -> Widget: ... @@ -97,6 +100,12 @@ class EditorSessionSurfaceLifetime(SessionSurfaceLifetime): def __init__(self) -> None: self.state: SessionViewState | None = None + @property + def initial_editing_mode(self) -> EditingMode: + if self.state is not None: + return self.state.editor.editing_mode + return super().initial_editing_mode + @abstractmethod async def release_binding( self, conversation: Conversation, screen: MainScreen @@ -360,8 +369,7 @@ async def acquire(self, screen: MainScreen): self.state.prepare_transcript(conversation) await self.transcript.bind(conversation).restore_documents(self.transcript) if self.state is not None: - if not await self.state.restore(conversation): - return + await self.state.restore(conversation) self.state = None await self.attach_binding(conversation) await self.transcript.bind(conversation).restore(self.transcript) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index e091d0c12..eff132af1 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -974,6 +974,11 @@ def compose(self) -> ComposeResult: model_history_scope=Conversation.model_history_scope, status=Conversation.status, ) + # A retained editor is installed before Prompt.on_mount can apply a + # profile. Returning to a tab must not spawn a throwaway Neovim. + prompt.initial_editing_mode = self.query_ancestor( + SessionScope + ).presentation.initial_editing_mode self.goal_bar: GoalBar = GoalBar(self, prompt=prompt) yield self.goal_bar yield prompt diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 6fbe0e092..fe4ff30b8 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -229,6 +229,7 @@ def __init__(self, *, prompt: Prompt) -> None: super().__init__() # The composer that built this editor owns its turns, popups and modes. self.prompt = prompt + self.set_reactive(TextArea.editing_mode, prompt.initial_editing_mode) self.set_reactive(PromptTextArea.project_path, prompt.project_path) self._submit_pending = False self._submit_immediate = False @@ -272,6 +273,7 @@ def highlight_shell(self, text: str) -> Content: def on_mount(self) -> None: self.highlight_cursor_line = False self.hide_suggestion_on_blur = False + self.editing_mode.attach(self) self.apply_keyboard_profile() self.call_after_refresh(lambda: self.show_editing_mode(self.editing_mode)) @@ -289,7 +291,7 @@ async def _apply_keyboard_profile(self) -> None: def on_neovim_updated(self, event: NeovimUpdated) -> None: event.stop() - event.snapshot.apply(self) + event.owner.render(self) @property def submission_allowed(self) -> bool: @@ -387,9 +389,6 @@ async def _submit(self) -> None: self._submit_pending = False mode = self.editing_mode try: - await mode.acknowledge(self) - if not self.submission_allowed: - return if not await mode.flush(self): return except EditingFailure: @@ -526,6 +525,7 @@ def __init__( super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.set_reactive(Prompt.project_path, project) self.ask_queue: list[Ask] = [] + self.initial_editing_mode: EditingMode = EditingMode() self.turns = turns self.submissions = submissions diff --git a/tests/textual/test_active_mode_keymap.py b/tests/textual/test_active_mode_keymap.py new file mode 100644 index 000000000..19b8362b3 --- /dev/null +++ b/tests/textual/test_active_mode_keymap.py @@ -0,0 +1,37 @@ +"""Application remaps work from shared sidebar focus and the Close button.""" + +from textual.widgets import Input + +from toad.application_actions import CommanderAction +from toad.key_modes import VimNormal +from toad.preferences import SETTINGS +from toad.screens.commander import CommanderPage +from toad.widgets.side_bar import SideBar +from toad.widget_actions import DismissAction + + +async def test_commander_remaps_from_sidebar_and_close_button(workspace): + async with workspace.app(size=(120, 40)) as ui: + app, _pilot = ui.textual + app.saved_settings.set(SETTINGS.ui.key_mode, VimNormal) + remaps = {CommanderAction.binding_id(VimNormal): "f3"} + app.set_keymap(remaps) + sidebar = app.workspace_chrome.channels + sidebar.reveal() + assert isinstance(sidebar, SideBar) + sidebar.query_one(Input).focus() + await ui.press("f3") + await ui.until(lambda: isinstance(app.screen, CommanderPage), + why="the application binding from shared sidebar focus") + app.set_keymap({**remaps, DismissAction.binding_id(VimNormal): "f6"}) + await ui.press("escape") + assert isinstance(app.screen, CommanderPage) + await ui.press("f6") + await ui.until(lambda: app.screen is app.workspace_screen, + why="the remapped modal dismissal") + await ui.press("f3") + await ui.until(lambda: isinstance(app.screen, CommanderPage), + why="the commander page reopened under the remap") + 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") From 11d1677161ba5a56893fc8a138315adf86be2b82 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:40:06 -0400 Subject: [PATCH 07/27] Decode RPC outcomes through their declarations and own retained surface states --- src/toad/neovim_editing.py | 187 +++++++++++++++++++++---------- src/toad/session_presentation.py | 66 +++++++---- src/toad/widgets/prompt.py | 2 +- 3 files changed, 171 insertions(+), 84 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 5676fcec5..618643f7f 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -10,12 +10,11 @@ from importlib.resources import files from itertools import count from typing import ClassVar -from weakref import ref import msgpack from agent_comms.child_process import AttachedChild, join_retirement from agent_comms.declared_family import DeclaredFamily, UnknownFamilyMember -from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.field_codec import FieldCodec, MalformedRecord, NullMember, SingleFieldValue, SubclassUnion, boundary_decoder from textual.binding import Binding from textual.cursor_shape import BlinkingBar, CursorShape, SteadyBlock from textual.document._document import Location, Selection @@ -297,28 +296,30 @@ def owns(self, editor: TextArea) -> bool: def visit(self, action: Callable[[TextArea], object]) -> None: pass + def boundary(self, binding: Binding) -> bool: + return False + class AttachedReceiver(DetachedReceiver): def __init__(self, editor: TextArea) -> None: - self._editor = ref(editor) + self._editor = editor def require(self) -> TextArea: - editor = self._editor() - if editor is None: - return super().require() - return editor + return self._editor def owns(self, editor: TextArea) -> bool: - return self._editor() is editor + return self._editor is editor def visit(self, action: Callable[[TextArea], object]) -> None: - editor = self._editor() - if editor is not None and editor.is_attached: - action(editor) + if self._editor.is_attached: + action(self._editor) + + def boundary(self, binding: Binding) -> bool: + return self._editor.is_attached and binding.engine_boundary(self._editor) class NeovimRequest(ABC): - """One admitted request; the writer never waits for its eventual reply.""" + """One queued request; the writer never waits for its eventual reply.""" _identities = count(1) @@ -438,13 +439,15 @@ def __init__(self, owner: NeovimEditing) -> None: self.owner = owner -class NeovimNotification(DeclaredFamily, affix="Notification"): +class NeovimMethod: method: ClassVar[str] @classmethod def selection_key(cls) -> str: return cls.method + +class NeovimNotification(NeovimMethod, DeclaredFamily, affix="Notification"): @classmethod @abstractmethod def receive(cls, owner: NeovimEditing, arguments: object) -> None: ... @@ -478,8 +481,82 @@ def receive(cls, owner: NeovimEditing, arguments: object) -> None: pass +class NeovimHostRequest(NeovimMethod, DeclaredFamily, affix="Request"): + @classmethod + @abstractmethod + def receive(cls, owner: NeovimEditing, arguments: object) -> object: ... + + +class CursorBoundaryRequest(NeovimHostRequest): + method = "toad_cursor_boundary" + + @classmethod + def receive(cls, owner: NeovimEditing, arguments: object) -> bool: + boundary, = FieldCodec.decode(tuple[int], arguments) + return owner.engine_boundary(boundary) + + +class NeovimReply(SubclassUnion, affix="Reply"): + @abstractmethod + def resolve(self, request: NeovimRequest, value: object) -> None: ... + + +class SuccessfulReply(NullMember, NeovimReply): + def resolve(self, request: NeovimRequest, value: object) -> None: + request.resolve(value) + + +@dataclass(frozen=True) +class ErrorReply(SingleFieldValue, NeovimReply): + details: tuple[int, str] + + def resolve(self, request: NeovimRequest, value: object) -> None: + code, message = self.details + request.reply.set_exception(NeovimWireFailure(f"Neovim {request.method} ({code}): {message}")) + + +class NeovimFrame(DeclaredFamily, affix="Frame"): + kind: ClassVar[int] + + @classmethod + def selection_key(cls) -> int: + return cls.kind + + @classmethod + @abstractmethod + async def receive(cls, owner: NeovimEditing, payload: object) -> None: ... + + +class ResponseFrame(NeovimFrame): + kind = 1 + + @classmethod + async def receive(cls, owner: NeovimEditing, payload: object) -> None: + identity, reply, value = FieldCodec.decode(tuple[int, NeovimReply, object], payload) + owner.resolve_reply(identity, reply, value) + + +class NotificationFrame(NeovimFrame): + kind = 2 + + @classmethod + async def receive(cls, owner: NeovimEditing, payload: object) -> None: + method, arguments = FieldCodec.decode(tuple[str, object], payload) + NeovimNotification.member_selected(method).receive(owner, arguments) + + +class RequestFrame(NeovimFrame): + kind = 0 + + @classmethod + async def receive(cls, owner: NeovimEditing, payload: object) -> None: + identity, method, arguments = FieldCodec.decode(tuple[int, str, object], payload) + reply = NeovimHostRequest.member_selected(method).receive(owner, arguments) + await owner.answer_request(identity, reply) + + class NeovimEditing(VimMode): - """One engine, one admission order, completed snapshots only from SafeState.""" + """One engine, one input queue, completed snapshots only from SafeState.""" initial_keys = "" @@ -540,7 +617,7 @@ def attach(self, editor: TextArea) -> None: changed_receiver = not self._receiver.owns(editor) self._receiver = AttachedReceiver(editor) started = self._started - if started.done() and not started.cancelled() and started.exception() is None: + if started.done() and not started.cancelled(): self.render(editor, force=changed_receiver) def resume(self, editor: TextArea) -> None: @@ -590,7 +667,7 @@ async def acquire_child() -> AttachedChild: return self._child_ready.set_result(child) async with asyncio.TaskGroup() as tasks: - tasks.create_task(self._write_admitted()) + tasks.create_task(self._write_queued()) await self._read(child) except* (EditingFailure, OSError, msgpack.UnpackException, UnicodeDecodeError, MalformedRecord) as errors: @@ -606,16 +683,18 @@ async def acquire_child() -> AttachedChild: request.reply.set_exception(error) self._pending.clear() + @boundary_decoder async def _read(self, child: AttachedChild) -> None: - assert child.stdout is not None + stream = child.stdout + assert stream is not None unpacker = msgpack.Unpacker(raw=False, strict_map_key=False, max_buffer_size=16 * 1024 * 1024) - while chunk := await child.stdout.read(65536): + while chunk := await stream.read(65536): unpacker.feed(chunk) for message in unpacker: await self._receive(message) raise NeovimWireFailure(f"Neovim exited: {await child.stderr_tail()}") - async def _write_admitted(self) -> None: + async def _write_queued(self) -> None: await self._started while True: request = await self._outgoing.get() @@ -632,7 +711,7 @@ async def _bootstrap(self, request: NeovimRequest) -> object: await request.send(self._child) return await request.result() - def _admit(self, request: NeovimRequest) -> asyncio.Task[object]: + def _enqueue(self, request: NeovimRequest) -> asyncio.Task[object]: try: self._register(request) except EditingFailure as error: @@ -649,10 +728,10 @@ def _next_generation(self) -> int: return self._generation def _call(self, method: str, arguments: Sequence[object]) -> asyncio.Future[object]: - return self._admit(NeovimOperation(self._next_generation(), method, arguments)) + return self._enqueue(NeovimOperation(self._next_generation(), method, arguments)) def _input(self, keys: str) -> asyncio.Future[object]: - return self._admit(NeovimInput(self._next_generation(), keys)) + return self._enqueue(NeovimInput(self._next_generation(), keys)) async def _start(self) -> None: await self._child_ready @@ -682,46 +761,32 @@ async def _start(self) -> None: await self._bootstrap(NeovimInput(self._next_generation(), self.initial_keys)) await self._bootstrap(NeovimCall("nvim_exec_lua", ["return ToadEditor.cursor(...)", [initial_cursor]])) + @boundary_decoder async def _receive(self, message: object) -> None: - """Decode the foreign MessagePack-RPC envelope once at the child boundary.""" - frame = FieldCodec.decode(tuple[object, ...], message) - if not frame: - raise NeovimWireFailure("Empty Neovim RPC message") - kind = FieldCodec.decode(int, frame[0]) - if kind == 1: - _, identity, error, result = FieldCodec.decode(tuple[int, int, object, object], frame) - try: - request = self._pending[identity] - except KeyError as failure: - raise NeovimWireFailure("Unexpected Neovim RPC response") from failure - if error is None: - request.resolve(result) - else: - request.reply.set_exception(NeovimWireFailure(f"Neovim {request.method}: {error}")) - del self._pending[identity] - elif kind == 2: - _, method, arguments = FieldCodec.decode(tuple[int, str, object], frame) - try: - notification = NeovimNotification.member_selected(method) - except UnknownFamilyMember as error: - raise NeovimWireFailure(str(error)) from error - notification.receive(self, arguments) - elif kind == 0: - _, identity, method, arguments = FieldCodec.decode(tuple[int, int, str, object], frame) - if method != "toad_cursor_boundary": - await self._child.write(msgpack.packb([1, identity, "Unsupported Neovim host request", None])) - return - boundary, = FieldCodec.decode(tuple[int], arguments) - if not 0 <= boundary < len(self._boundaries): - raise NeovimWireFailure("Unknown Neovim cursor boundary") - handled = False - def offer(editor: TextArea) -> None: - nonlocal handled - handled = self._boundaries[boundary].engine_boundary(editor) - self._receiver.visit(offer) - await self._child.write(msgpack.packb([1, identity, None, handled])) - else: - raise NeovimWireFailure("Invalid Neovim RPC envelope") + """Decode the envelope tag here; its member alone decodes the raw payload.""" + if not isinstance(message, list) or not message: + raise NeovimWireFailure("Invalid Neovim RPC message") + kind = FieldCodec.decode(int, message[0]) + try: + await NeovimFrame.member_selected(kind).receive(self, message[1:]) + except UnknownFamilyMember as error: + raise NeovimWireFailure(str(error)) from error + + def resolve_reply(self, identity: int, reply: NeovimReply, value: object) -> None: + try: + request = self._pending[identity] + except KeyError as error: + raise NeovimWireFailure("Unexpected Neovim RPC response") from error + reply.resolve(request, value) + del self._pending[identity] + + def engine_boundary(self, boundary: int) -> bool: + if not 0 <= boundary < len(self._boundaries): + raise NeovimWireFailure("Unknown Neovim cursor boundary") + return self._receiver.boundary(self._boundaries[boundary]) + + async def answer_request(self, identity: int, reply: object) -> None: + await self._child.write(msgpack.packb([1, identity, None, reply])) def accept_lines(self, change: NeovimLineChange) -> None: if change.last == -1: diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index fe003c532..340a19ffe 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -36,8 +36,30 @@ class SessionSurfaceSlot(Widget): DEFAULT_CSS = "SessionSurfaceSlot { display: none; }" +class NativeEditorSelection: + @property + def initial_editing_mode(self) -> EditingMode: + return EditingMode() + + +class SessionViewState(NativeEditorSelection): + """An uncaptured surface has no retained resources to restore or close.""" + + def prepare_conversation(self, conversation: Conversation) -> None: + pass + + def prepare_transcript(self, conversation: Conversation) -> None: + pass + + async def restore(self, conversation: Conversation) -> None: + pass + + async def close(self) -> None: + pass + + @dataclass(frozen=True) -class SessionViewState: +class RetainedSessionViewState(SessionViewState): """The actual editor document/history and reader intent, not a transcript copy.""" editor: TextAreaState @@ -48,7 +70,7 @@ class SessionViewState: initial_prompt: str | None @classmethod - def capture(cls, conversation: Conversation) -> SessionViewState: + def capture(cls, conversation: Conversation) -> RetainedSessionViewState: editor = conversation.prompt.prompt_text_area return cls( editor.capture_editor_state(), @@ -59,6 +81,16 @@ def capture(cls, conversation: Conversation) -> SessionViewState: conversation.take_initial_prompt(), ) + @property + def initial_editing_mode(self) -> EditingMode: + return self.editor.editing_mode + + def prepare_conversation(self, conversation: Conversation) -> None: + conversation._initial_prompt = self.initial_prompt + + async def close(self) -> None: + await self.editor.editing_mode.close() + def prepare_transcript(self, conversation: Conversation) -> None: conversation.visible_categories = self.visible_categories conversation.window.reader_position = self.reader_position @@ -70,17 +102,13 @@ async def restore(self, conversation: Conversation) -> None: editor.shell_mode = self.shell_mode -class SessionSurfaceLifetime(ABC): +class SessionSurfaceLifetime(NativeEditorSelection, ABC): """A session declares its own UI placement and activation lifetime.""" @property def operational_binding(self): return NoAgentConnection() - @property - def initial_editing_mode(self) -> EditingMode: - return EditingMode() - @abstractmethod def compose_content(self, screen: MainScreen) -> Widget: ... @@ -98,13 +126,11 @@ class EditorSessionSurfaceLifetime(SessionSurfaceLifetime): """Own retained editor state and active shell close semantics.""" def __init__(self) -> None: - self.state: SessionViewState | None = None + self.state: SessionViewState = SessionViewState() @property def initial_editing_mode(self) -> EditingMode: - if self.state is not None: - return self.state.editor.editing_mode - return super().initial_editing_mode + return self.state.initial_editing_mode @abstractmethod async def release_binding( @@ -360,17 +386,14 @@ async def acquire(self, screen: MainScreen): self.widget = conversation screen._initial_prompt = None if not returning: - if self.state is not None: - conversation._initial_prompt = self.state.initial_prompt + self.state.prepare_conversation(conversation) await content.mount(conversation, before=slot) screen.thread_identity.bind_history(conversation.input_histories) yield conversation - if self.state is not None: - self.state.prepare_transcript(conversation) + self.state.prepare_transcript(conversation) await self.transcript.bind(conversation).restore_documents(self.transcript) - if self.state is not None: - await self.state.restore(conversation) - self.state = None + await self.state.restore(conversation) + self.state = SessionViewState() await self.attach_binding(conversation) await self.transcript.bind(conversation).restore(self.transcript) if returning: @@ -378,9 +401,8 @@ async def acquire(self, screen: MainScreen): conversation.prompt.focus() async def close(self, screen: MainScreen) -> None: - if self.state is not None: - await self.state.editor.editing_mode.close() - self.state = None + await self.state.close() + self.state = SessionViewState() async with AsyncResourceRelease() as release: release.push_async_callback(self.sources.close) release.push_async_callback(self.transcript.close) @@ -393,7 +415,7 @@ async def evict(self, screen: MainScreen) -> None: editor = conversation.prompt.prompt_text_area if not await editor.editing_mode.flush(editor): return # Retain the actual view/engine while its modal input is pending. - self.state = SessionViewState.capture(conversation) + self.state = RetainedSessionViewState.capture(conversation) await self.remove_native_tree(screen) def external_transcript_changed(self) -> None: diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index fe4ff30b8..4c0028f7c 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -286,7 +286,7 @@ def apply_keyboard_profile(self) -> None: async def _apply_keyboard_profile(self) -> None: mode = self.screen.key_mode - if type(self.editing_mode) is not mode: + if self.key_mode is not mode: await self.switch_editing_mode(mode()) def on_neovim_updated(self, event: NeovimUpdated) -> None: From 11386d35406f67e5bae1c5289a8f7a4228feae52 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:51:07 -0400 Subject: [PATCH 08/27] Use module identity and direct editing capability imports --- src/toad/neovim_editing.py | 3 ++- src/toad/prompt_cursor.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 618643f7f..0086a8e8d 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -12,6 +12,7 @@ from typing import ClassVar import msgpack +import toad from agent_comms.child_process import AttachedChild, join_retirement from agent_comms.declared_family import DeclaredFamily, UnknownFamilyMember from agent_comms.field_codec import FieldCodec, MalformedRecord, NullMember, SingleFieldValue, SubclassUnion, boundary_decoder @@ -748,7 +749,7 @@ async def _start(self) -> None: row, column = initial_cursor await self._bootstrap(NeovimCall("nvim_win_set_cursor", [0, [row + 1, len(initial_lines[row][:column].encode("utf-8"))]])) - script = files("toad").joinpath("neovim_editing.lua").read_text() + script = files(toad).joinpath("neovim_editing.lua").read_text() mappings: list[list[object]] = [] for key, binding in editor.binding_map(type(self)): for spelling, native in binding.declared_key.input_bindings(key): diff --git a/src/toad/prompt_cursor.py b/src/toad/prompt_cursor.py index ec7e535ef..13cd68719 100644 --- a/src/toad/prompt_cursor.py +++ b/src/toad/prompt_cursor.py @@ -9,11 +9,13 @@ from agent_comms.declared_family import DeclaredFamily from textual.content import Content +from textual.document._editing import EditingMode from textual.widgets import TextArea from textual.widgets.text_area import Selection +from toad.core.input_events import HistoryMove + if TYPE_CHECKING: - from textual.document._editing import EditingMode from toad.slash_command import SlashCommand from toad.widgets.prompt import PromptTextArea @@ -91,8 +93,6 @@ def at_edge(cls, editor: PromptTextArea) -> bool: ... @classmethod def try_move(cls, editor: PromptTextArea, select: bool, mode: EditingMode) -> bool: - from toad.core.input_events import HistoryMove - if (mode.allows_history_navigation and editor.selection.is_empty and not select): if cls.at_edge(editor): From e9c7917ae8dd949b5b50191b700e4179abe321f2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:57:04 -0400 Subject: [PATCH 09/27] Invalidate editor completion on asynchronous buffer changes --- src/toad/neovim_editing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 0086a8e8d..20e5c5190 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -794,6 +794,7 @@ def accept_lines(self, change: NeovimLineChange) -> None: self._changes.append(NeovimBufferTransfer(change.lines)) else: self._changes.append(change) + self._publish(NeovimSnapshot(self.snapshot.state)) def accept_display(self, display: NeovimDisplay) -> None: self._publish(NeovimSnapshot(self.snapshot.state.update(display), display.completed)) @@ -809,7 +810,7 @@ def render(self, editor: TextArea, *, force: bool = False) -> None: self._update_queued = False if editor.editing_mode is not self or not self._receiver.owns(editor): return - if self.snapshot is self._rendered and not force: + if self.snapshot is self._rendered and not force and not self._changes: return previous, current = self._rendered.state, self.snapshot.state changes, self._changes = self._changes, [] From 4ed3cfee94ec6dc2ca0ded99ce34d328ddf874e6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:09:45 -0400 Subject: [PATCH 10/27] Use the actual engine display at history boundaries --- src/toad/neovim_editing.lua | 2 +- src/toad/neovim_editing.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua index f520cebd3..a9bf3c73c 100644 --- a/src/toad/neovim_editing.lua +++ b/src/toad/neovim_editing.lua @@ -98,7 +98,7 @@ for _, mapping in ipairs(mappings) do for _, mode in ipairs({'', 'i'}) do vim.keymap.set(mode, lhs, function() if vim.v.count == 0 and not vim.fn.mode(1):match('^no') then - if vim.rpcrequest(1, 'toad_cursor_boundary', boundary) then return '' end + if vim.rpcrequest(1, 'toad_cursor_boundary', boundary, state()) then return '' end end return api.nvim_replace_termcodes(rhs, true, false, true) end, {expr = true, noremap = true, silent = true}) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 20e5c5190..7a88aa8bf 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -493,8 +493,8 @@ class CursorBoundaryRequest(NeovimHostRequest): @classmethod def receive(cls, owner: NeovimEditing, arguments: object) -> bool: - boundary, = FieldCodec.decode(tuple[int], arguments) - return owner.engine_boundary(boundary) + boundary, display = FieldCodec.decode(tuple[int, NeovimDisplay], arguments) + return owner.engine_boundary(boundary, display) class NeovimReply(SubclassUnion, affix="Reply"): @@ -781,9 +781,11 @@ def resolve_reply(self, identity: int, reply: NeovimReply, value: object) -> Non reply.resolve(request, value) del self._pending[identity] - def engine_boundary(self, boundary: int) -> bool: + def engine_boundary(self, boundary: int, display: NeovimDisplay) -> bool: if not 0 <= boundary < len(self._boundaries): raise NeovimWireFailure("Unknown Neovim cursor boundary") + self.accept_display(display) + self._receiver.visit(self.render) return self._receiver.boundary(self._boundaries[boundary]) async def answer_request(self, identity: int, reply: object) -> None: From 4d4638f5ed9c341ab401757f4325a1f46dde6b40 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:25:09 -0400 Subject: [PATCH 11/27] Keep in-command history displays incomplete --- src/toad/neovim_editing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 7a88aa8bf..aeebeb145 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -784,7 +784,8 @@ def resolve_reply(self, identity: int, reply: NeovimReply, value: object) -> Non def engine_boundary(self, boundary: int, display: NeovimDisplay) -> bool: if not 0 <= boundary < len(self._boundaries): raise NeovimWireFailure("Unknown Neovim cursor boundary") - self.accept_display(display) + # A mapping's in-command view cannot certify SafeState completion. + self._publish(NeovimSnapshot(self.snapshot.state.update(display))) self._receiver.visit(self.render) return self._receiver.boundary(self._boundaries[boundary]) From 2af945cc0c214f7c61466f44812e24780edc164a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:58:44 -0400 Subject: [PATCH 12/27] Publish document projection before the engine display --- src/toad/neovim_editing.py | 109 +++++++++++++++++++++++++++++-------- src/toad/widgets/prompt.py | 2 +- 2 files changed, 87 insertions(+), 24 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index aeebeb145..635f25c76 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -18,7 +18,7 @@ from agent_comms.field_codec import FieldCodec, MalformedRecord, NullMember, SingleFieldValue, SubclassUnion, boundary_decoder from textual.binding import Binding from textual.cursor_shape import BlinkingBar, CursorShape, SteadyBlock -from textual.document._document import Location, Selection +from textual.document._document import DocumentBase, Location, RangedEditResult, Selection from textual.document._editing import EditingFailure, EditingMode, VimMode from textual.document._edit import Edit, InsertionPoint from textual.events import Key @@ -288,6 +288,13 @@ class BlockVisualState(VisualState): class DetachedReceiver: """An engine may outlive its widget; detachment is not child failure.""" + def attach(self, editor: TextArea) -> AttachedReceiver: + self.prepare_editor(editor) + return AttachedReceiver(editor) + + def prepare_editor(self, editor: TextArea) -> None: + pass + def require(self) -> TextArea: raise EditingFailure("Neovim requires an attached editor") @@ -300,11 +307,49 @@ def visit(self, action: Callable[[TextArea], object]) -> None: def boundary(self, binding: Binding) -> bool: return False + def project(self, changes: Sequence[NeovimChange]) -> None: + raise EditingFailure("Neovim has no document to project into") + + def finish_projection(self) -> None: + pass + + +class RetainedReceiver(DetachedReceiver): + """A captured document remains the projection without retaining its widget.""" + + def __init__(self, document: DocumentBase) -> None: + self._document = document + + @property + def document(self) -> DocumentBase: + return self._document + + 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: + for change in changes: + self.project_edit(change.apply(self.document)) + + def project_edit(self, result: RangedEditResult) -> None: + pass + -class AttachedReceiver(DetachedReceiver): +class AttachedReceiver(RetainedReceiver): def __init__(self, editor: TextArea) -> None: self._editor = editor + @property + def document(self) -> DocumentBase: + return self._editor.document + + def project_edit(self, result: RangedEditResult) -> None: + self._editor.project_engine_edit(result) + + def finish_projection(self) -> None: + self._editor.finish_engine_changes() + def require(self) -> TextArea: return self._editor @@ -407,7 +452,7 @@ def resolve(self, value: object) -> None: class NeovimChange(ABC): @abstractmethod - def apply(self, editor: TextArea) -> None: ... + def apply(self, document: DocumentBase) -> RangedEditResult: ... @dataclass(frozen=True) @@ -416,16 +461,16 @@ class NeovimLineChange(NeovimChange): last: int lines: tuple[str, ...] - def apply(self, editor: TextArea) -> None: - editor.apply_engine_lines(self.first, self.last, self.lines) + def apply(self, document: DocumentBase) -> RangedEditResult: + return document.replace_lines(self.first, self.last, self.lines) @dataclass(frozen=True) class NeovimBufferTransfer(NeovimChange): lines: tuple[str, ...] - def apply(self, editor: TextArea) -> None: - editor.apply_engine_text("\n".join(self.lines)) + def apply(self, document: DocumentBase) -> RangedEditResult: + return document.replace_lines(0, document.line_count, self.lines) @dataclass(frozen=True) @@ -439,6 +484,15 @@ def __init__(self, owner: NeovimEditing) -> None: super().__init__() self.owner = owner + def paint(self, editor: TextArea) -> None: + owner = self.owner + if not owner._receiver.owns(editor): + return + owner._update_queued = False + # A later line batch may precede its display while this message waits. + if not owner._changes: + owner.render(editor) + class NeovimMethod: method: ClassVar[str] @@ -616,9 +670,12 @@ def _started(self) -> asyncio.Task[None]: def attach(self, editor: TextArea) -> None: changed_receiver = not self._receiver.owns(editor) - self._receiver = AttachedReceiver(editor) + self._receiver = self._receiver.attach(editor) + if changed_receiver: + self._update_queued = False + editor.history.clear() started = self._started - if started.done() and not started.cancelled(): + if started.done() and not started.cancelled() and not self._changes: self.render(editor, force=changed_receiver) def resume(self, editor: TextArea) -> None: @@ -628,7 +685,7 @@ def suspend(self, editor: TextArea) -> None: pass def capture(self, editor: TextArea) -> NeovimEditing: - self._receiver = DetachedReceiver() + self._receiver = RetainedReceiver(editor.document) return self async def unmount(self, editor: TextArea) -> None: @@ -785,7 +842,7 @@ def engine_boundary(self, boundary: int, display: NeovimDisplay) -> bool: if not 0 <= boundary < len(self._boundaries): raise NeovimWireFailure("Unknown Neovim cursor boundary") # A mapping's in-command view cannot certify SafeState completion. - self._publish(NeovimSnapshot(self.snapshot.state.update(display))) + self.publish(NeovimSnapshot(self.snapshot.state.update(display))) self._receiver.visit(self.render) return self._receiver.boundary(self._boundaries[boundary]) @@ -800,27 +857,32 @@ def accept_lines(self, change: NeovimLineChange) -> None: self._publish(NeovimSnapshot(self.snapshot.state)) def accept_display(self, display: NeovimDisplay) -> None: - self._publish(NeovimSnapshot(self.snapshot.state.update(display), display.completed)) + self.publish(NeovimSnapshot(self.snapshot.state.update(display), display.completed)) + + 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._publish(snapshot) + if changes: + self._receiver.finish_projection() def _publish(self, snapshot: NeovimSnapshot) -> None: self.snapshot = snapshot - if not self._update_queued: + if not self._changes and not self._update_queued: def post(editor: TextArea) -> None: self._update_queued = editor.post_message(NeovimUpdated(self)) self._receiver.visit(post) def render(self, editor: TextArea, *, force: bool = False) -> None: - self._update_queued = False if editor.editing_mode is not self or not self._receiver.owns(editor): return - if self.snapshot is self._rendered and not force and not self._changes: + if self._changes: + raise RuntimeError("Cannot paint an unpublished Neovim line batch") + if self.snapshot is self._rendered and not force: return previous, current = self._rendered.state, self.snapshot.state - changes, self._changes = self._changes, [] - for change in changes: - change.apply(editor) - if changes: - editor.finish_engine_changes() if force or previous.cursor != current.cursor: editor.apply_engine_selection(Selection.cursor(current.cursor)) editor.record_cursor_width() @@ -845,7 +907,7 @@ def _fail(self, error: EditingFailure) -> None: failed = previous.fail(error) if failed is previous: return - self._publish(NeovimSnapshot(failed)) + self.publish(NeovimSnapshot(failed)) def notify(editor: TextArea) -> None: editor.notify(f"{error}. The displayed draft is preserved. Input was not replayed.", title="Neovim editing failed", severity="error", timeout=0) @@ -857,15 +919,16 @@ async def finish(self, editor: TextArea) -> bool: async def flush(self, editor: TextArea) -> bool: """Never wait for deferred RPC in App dispatch. Missing completion refuses.""" self.snapshot.state.raise_failure() + if not self.completed: + return False self.render(editor) - return self.completed and self.snapshot.state.transferable + return self.snapshot.state.transferable async def prepare_input(self, editor: TextArea) -> bool: # Only fence the widget queue, never the Neovim event loop. Escape must # remain admissible while a deferred operation is waiting for an operand. finished: asyncio.Future[bool] = asyncio.get_running_loop().create_future() def ready() -> None: - self.render(editor) finished.set_result(True) if not editor.call_later(ready): return False diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 4c0028f7c..97027e80f 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -291,7 +291,7 @@ async def _apply_keyboard_profile(self) -> None: def on_neovim_updated(self, event: NeovimUpdated) -> None: event.stop() - event.owner.render(self) + event.paint(self) @property def submission_allowed(self) -> bool: From 237bc5a5815bdd52594f138b75eb15bfe38568f8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:07:11 -0400 Subject: [PATCH 13/27] Repaint restored view resources on editor attachment --- src/toad/neovim_editing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 635f25c76..b4fad6c5f 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -676,7 +676,8 @@ def attach(self, editor: TextArea) -> None: editor.history.clear() started = self._started if started.done() and not started.cancelled() and not self._changes: - self.render(editor, force=changed_receiver) + # Restore may have rebuilt view resources for this same receiver. + self.render(editor, force=True) def resume(self, editor: TextArea) -> None: pass From b2d0218b0af3ed0ba83a684527de224a94a961e6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:19:44 -0400 Subject: [PATCH 14/27] Keep an unpainted baseline until the retained view is presented --- src/toad/neovim_editing.py | 67 +++++++++++++++++++++----------------- src/toad/widgets/prompt.py | 2 +- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index b4fad6c5f..d13c04cf5 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -308,7 +308,7 @@ def boundary(self, binding: Binding) -> bool: return False def project(self, changes: Sequence[NeovimChange]) -> None: - raise EditingFailure("Neovim has no document to project into") + pass def finish_projection(self) -> None: pass @@ -478,21 +478,24 @@ class NeovimSnapshot: state: NeovimState completed: int = -1 + def matches[Answer](self, current: NeovimState, + answer: Callable[[NeovimState], Answer]) -> bool: + return answer(self.state) == answer(current) + + +class UnpaintedSnapshot(NeovimSnapshot): + """A new view has presented none of the retained owner's answers yet.""" + + def matches[Answer](self, current: NeovimState, + answer: Callable[[NeovimState], Answer]) -> bool: + return False + class NeovimUpdated(Message): def __init__(self, owner: NeovimEditing) -> None: super().__init__() self.owner = owner - def paint(self, editor: TextArea) -> None: - owner = self.owner - if not owner._receiver.owns(editor): - return - owner._update_queued = False - # A later line batch may precede its display while this message waits. - if not owner._changes: - owner.render(editor) - class NeovimMethod: method: ClassVar[str] @@ -622,7 +625,7 @@ def __init__(self) -> None: self._generation = 0 self.snapshot = NeovimSnapshot(StartingState()) self._changes: list[NeovimChange] = [] - self._rendered = self.snapshot + self._rendered: NeovimSnapshot = UnpaintedSnapshot(self.snapshot.state) self._update_queued = False self._boundaries: list[Binding] = [] @@ -674,10 +677,11 @@ def attach(self, editor: TextArea) -> None: if changed_receiver: self._update_queued = False editor.history.clear() + # Even the same receiver may have rebuilt its width-dependent view. + self._rendered = UnpaintedSnapshot(self._rendered.state) started = self._started - if started.done() and not started.cancelled() and not self._changes: - # Restore may have rebuilt view resources for this same receiver. - self.render(editor, force=True) + if started.done() and not started.cancelled(): + self.paint(editor) def resume(self, editor: TextArea) -> None: pass @@ -855,7 +859,7 @@ def accept_lines(self, change: NeovimLineChange) -> None: self._changes.append(NeovimBufferTransfer(change.lines)) else: self._changes.append(change) - self._publish(NeovimSnapshot(self.snapshot.state)) + self.snapshot = NeovimSnapshot(self.snapshot.state) def accept_display(self, display: NeovimDisplay) -> None: self.publish(NeovimSnapshot(self.snapshot.state.update(display), display.completed)) @@ -865,33 +869,38 @@ def publish(self, snapshot: NeovimSnapshot) -> None: changes, self._changes = self._changes, [] if changes: self._receiver.project(changes) - self._publish(snapshot) + self.snapshot = snapshot if changes: self._receiver.finish_projection() - - def _publish(self, snapshot: NeovimSnapshot) -> None: - self.snapshot = snapshot - if not self._changes and not self._update_queued: + if not self._update_queued: def post(editor: TextArea) -> None: self._update_queued = editor.post_message(NeovimUpdated(self)) self._receiver.visit(post) - def render(self, editor: TextArea, *, force: bool = False) -> None: + def paint(self, editor: TextArea) -> None: + if not self._receiver.owns(editor): + return + self._update_queued = False + # A later line batch may precede its display while a message waits. + if not self._changes: + self.render(editor) + + def render(self, editor: TextArea) -> None: if editor.editing_mode is not self or not self._receiver.owns(editor): return if self._changes: raise RuntimeError("Cannot paint an unpublished Neovim line batch") - if self.snapshot is self._rendered and not force: + if self.snapshot is self._rendered: return - previous, current = self._rendered.state, self.snapshot.state - if force or previous.cursor != current.cursor: - editor.apply_engine_selection(Selection.cursor(current.cursor)) + 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 previous.regions != current.regions: - editor.invalidate_engine_regions((*previous.regions, *current.regions)) - if force or previous.label != current.label or previous.command_line != current.command_line: + 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)): editor.show_editing_mode(self) - if force or previous.binding_answers != current.binding_answers: + if not previous.matches(current, lambda state: state.binding_answers): editor.refresh_bindings() self._rendered = self.snapshot diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 97027e80f..5896270b4 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -291,7 +291,7 @@ async def _apply_keyboard_profile(self) -> None: def on_neovim_updated(self, event: NeovimUpdated) -> None: event.stop() - event.paint(self) + event.owner.paint(self) @property def submission_allowed(self) -> bool: From c29eaa12c6245624e30e4edabfcb4032aadb62e4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:46:16 -0400 Subject: [PATCH 15/27] Track Textual main with the merged Neovim editing capability --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 521731262..c9ec12e35 100644 --- a/uv.lock +++ b/uv.lock @@ -1328,7 +1328,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#e7a48c59e22d3a4ea9ebcb64c5a2dcb40673f780" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#7ba616bd55c6ee40f156def5cb86ba28b0c0e16b" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From d036dc588503318decc330f63dc68fff5d26fb51 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:56:11 -0400 Subject: [PATCH 16/27] Diff the debt ratchet from the PR's merge-base, not GitHub's stale base.sha pull_request.base.sha is not refreshed when main moves. toad#1320 at c29eaa12c was diffed from 6d83f98ac, fifteen main commits behind its real merge-base 4c3c4f3a0, and charged with a NoneAbsence at screens/main.py:231 and a BroadSwallow at session_navigation.py:319 that main commits 776f7f928 and 961e7ca7b introduced; #1320 touches neither file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b1edccf5..5835d846f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,12 @@ jobs: PY - name: Compare production source env: - PR_BASE: ${{ github.event.pull_request.base.sha || inputs.base }} + # pull_request.base.sha is not refreshed when the base branch moves, so it can name a + # revision many commits behind and charge the PR for debt main introduced. The PR's own + # change starts at the merge-base with the base branch's current tip. + BASE_TIP: ${{ github.event.pull_request.base.ref && format('origin/{0}', github.event.pull_request.base.ref) || inputs.base }} PR_HEAD: ${{ github.event.pull_request.head.sha || inputs.head }} - run: PYTHONPATH="$PWD/.core-ratchet/src" PATH="$PWD/.venv/bin:$PATH" .venv/bin/python .core-ratchet/tools/debt_ratchet.py --root src/toad --base "$PR_BASE" --head "$PR_HEAD" + run: PYTHONPATH="$PWD/.core-ratchet/src" PATH="$PWD/.venv/bin:$PATH" .venv/bin/python .core-ratchet/tools/debt_ratchet.py --root src/toad --base "$(git merge-base "$BASE_TIP" "$PR_HEAD")" --head "$PR_HEAD" - name: Run surface guards run: .venv/bin/pytest tests/guards suite: From 89ac1bfaff01398559f01a496bc079cc54fd8e69 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:50:33 -0400 Subject: [PATCH 17/27] Decode native Neovim handles at the RPC boundary --- .github/actions/core-from-main/action.yml | 15 +- .github/workflows/ci.yml | 10 +- src/toad/neovim_editing.lua | 10 +- src/toad/neovim_editing.py | 178 ++++++++++++++++++---- tests/textual/test_neovim_wire.py | 50 ++++++ 5 files changed, 216 insertions(+), 47 deletions(-) create mode 100644 tests/textual/test_neovim_wire.py diff --git a/.github/actions/core-from-main/action.yml b/.github/actions/core-from-main/action.yml index 4d2baacf6..3d260ebfc 100644 --- a/.github/actions/core-from-main/action.yml +++ b/.github/actions/core-from-main/action.yml @@ -1,8 +1,8 @@ name: Core from main description: >- - Toad tracks sibling branches declared in pyproject.toml. This refreshes those sources, - syncs the environment from that lock, and checks Core out at the commit the lock - resolved, so one run has one answer to which Core it runs. + Refresh Core from its declared branch while preserving the candidate's locked + sibling dependencies. Sync that environment and check Core out at the resolved + commit, so verification does not silently replace a paired dependency candidate. inputs: path: description: Where to check Core out @@ -18,14 +18,13 @@ runs: run: | python - <<'PY' import os, pathlib, subprocess, sys, tomllib - sources = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['tool']['uv']['sources'] - updates = [argument for name, source in sources.items() if 'branch' in source - for argument in ('--upgrade-package', name)] - subprocess.run(['uv', 'lock', '--python', sys.executable, *updates], check=True) + package_name = 'agent-comms' + subprocess.run(['uv', 'lock', '--python', sys.executable, + '--upgrade-package', package_name], check=True) subprocess.run(['uv', 'sync', '--python', sys.executable, '--locked', '--all-extras', '--no-editable'], check=True) package, = (p for p in tomllib.loads(pathlib.Path('uv.lock').read_text())['package'] - if p['name'] == 'agent-comms') + if p['name'] == package_name) url, commit = package['source']['git'].split('#') repository = url.split('?')[0].removeprefix('https://github.com/').removesuffix('.git') print(f'Core resolved from main: {repository}@{commit}') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f56bc0ad..fb3a432b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,14 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.14' + - name: Install Neovim + run: | + curl --fail --location --silent --show-error \ + https://github.com/neovim/neovim/releases/download/v0.12.4/nvim-linux-x86_64.tar.gz \ + --output "$RUNNER_TEMP/neovim.tar.gz" + tar --extract --gzip --file "$RUNNER_TEMP/neovim.tar.gz" --directory "$RUNNER_TEMP" + echo "$RUNNER_TEMP/nvim-linux-x86_64/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/nvim-linux-x86_64/bin/nvim" --version - uses: ./.github/actions/core-from-main with: path: .artifacts/core @@ -83,4 +91,4 @@ jobs: # xdist reports a collection error as a worker crash; collect alone first so it reads as one. - run: .venv/bin/pytest --collect-only -q # A test stuck past 120 s dumps every thread, so the red names where it hung. - - run: .venv/bin/pytest -n 2 --basetemp .artifacts/pilots -o faulthandler_timeout=120 -rfE + - run: .venv/bin/pytest -n 2 --basetemp .artifacts/pilots -o faulthandler_timeout=120 -rfE -x -vv --tb=short diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua index a9bf3c73c..45e5fa649 100644 --- a/src/toad/neovim_editing.lua +++ b/src/toad/neovim_editing.lua @@ -1,5 +1,5 @@ -- Application integration, not a Vim interpreter. Neovim interprets every key. -local mappings = ... +local mappings, channel = ... local api = vim.api local namespace = api.nvim_create_namespace('toad-editor') vim.opt.swapfile = false @@ -43,7 +43,7 @@ local function state() regions = regions, selected = selected, completed = completed} end local function publish() - vim.rpcnotify(1, 'toad_editor_state', state()) + vim.rpcnotify(channel, 'toad_editor_state', state()) end local function schedule_publish() if scheduled then return end @@ -55,7 +55,7 @@ end -- an ownership transfer, not another callback registration. local subscriptions = {} local function entered(buffer) - vim.rpcnotify(1, 'toad_editor_lines', {first = 0, last = -1, + vim.rpcnotify(channel, 'toad_editor_lines', {first = 0, last = -1, lines = api.nvim_buf_get_lines(buffer, 0, -1, true)}) if subscriptions[buffer] then return end subscriptions[buffer] = true @@ -63,7 +63,7 @@ local function entered(buffer) on_lines = function(_, buf, tick, first, last, new_last) if api.nvim_get_current_buf() == buf then completed = -1 - vim.rpcnotify(1, 'toad_editor_lines', {first = first, last = last, + vim.rpcnotify(channel, 'toad_editor_lines', {first = first, last = last, lines = api.nvim_buf_get_lines(buf, first, new_last, true)}) schedule_publish() end @@ -98,7 +98,7 @@ for _, mapping in ipairs(mappings) do for _, mode in ipairs({'', 'i'}) do vim.keymap.set(mode, lhs, function() if vim.v.count == 0 and not vim.fn.mode(1):match('^no') then - if vim.rpcrequest(1, 'toad_cursor_boundary', boundary, state()) then return '' end + if vim.rpcrequest(channel, 'toad_cursor_boundary', boundary, state()) then return '' end end return api.nvim_replace_termcodes(rhs, true, false, true) end, {expr = true, noremap = true, silent = true}) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index d13c04cf5..38ac91b91 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -3,13 +3,13 @@ import asyncio from abc import ABC, abstractmethod -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from contextlib import AsyncExitStack from dataclasses import dataclass, field from functools import cached_property from importlib.resources import files from itertools import count -from typing import ClassVar +from typing import ClassVar, Self import msgpack import toad @@ -80,6 +80,59 @@ class NeovimWireFailure(EditingFailure): """A child, transport or external Neovim protocol failure, not a Vim prefix.""" +@dataclass(frozen=True) +class NeovimApiType: + id: int + prefix: str + + +@dataclass(frozen=True) +class NeovimApiInfo: + channel: int + types: dict[str, NeovimApiType] + + @classmethod + @boundary_decoder + def from_response(cls, response: object) -> Self: + if not isinstance(response, list) or len(response) != 2: + raise NeovimWireFailure("Invalid Neovim API information response") + channel, metadata = response + if not isinstance(metadata, dict): + raise NeovimWireFailure("Invalid Neovim API metadata") + try: + definitions = metadata["types"] + except KeyError as error: + raise NeovimWireFailure("Neovim API metadata has no extension types") from error + return cls(FieldCodec.decode(int, channel), + FieldCodec.decode(dict[str, NeovimApiType], definitions)) + + +@dataclass(frozen=True) +class NeovimHandle(DeclaredFamily, affix="Handle"): + """Native extension identity; its code belongs to this child's API metadata.""" + + value: int + + @classmethod + def api_name(cls) -> str: + return cls.declared_name.capitalize() + + def extension(self, types: Mapping[str, NeovimApiType]) -> msgpack.ExtType: + return msgpack.ExtType(types[self.api_name()].id, msgpack.packb(self.value)) + + +class BufferHandle(NeovimHandle): + pass + + +class WindowHandle(NeovimHandle): + pass + + +class TabpageHandle(NeovimHandle): + pass + + @dataclass(frozen=True) class NeovimMode: """The actual nvim_get_mode answer, decoded once inside a display update.""" @@ -382,13 +435,9 @@ def method(self) -> str: ... @abstractmethod def arguments(self) -> Sequence[object]: ... - async def send(self, child: AttachedChild) -> None: + async def send(self, owner: NeovimEditing) -> None: self.dispatched.set_result(None) - try: - await child.write(msgpack.packb( - [0, self.identity, self.method, list(self.arguments)], use_bin_type=True)) - except OSError as error: - raise NeovimWireFailure(str(error)) from error + await owner.write_frame([0, self.identity, self.method, list(self.arguments)]) def resolve(self, value: object) -> None: self.reply.set_result(value) @@ -580,37 +629,61 @@ class NeovimFrame(DeclaredFamily, affix="Frame"): def selection_key(cls) -> int: return cls.kind + @abstractmethod + def __init__(self, *payload: object) -> None: + if payload: + raise NeovimWireFailure("Unexpected Neovim RPC header fields") + @classmethod + @boundary_decoder + def from_payload(cls, payload: Sequence[object]) -> Self: + try: + return cls(*payload) + except TypeError as error: + raise NeovimWireFailure(f"Invalid {cls.__name__} header or arity") from error + @abstractmethod - async def receive(cls, owner: NeovimEditing, payload: object) -> None: ... + async def receive(self, owner: NeovimEditing) -> None: ... + + +class IdentifiedFrame(NeovimFrame, declared_name=None): + def __init__(self, identity: object, *payload: object) -> None: + self.identity = FieldCodec.decode(int, identity) + super().__init__(*payload) + + +class MethodFrame(NeovimFrame, declared_name=None): + def __init__(self, method: object, arguments: object) -> None: + self.method = FieldCodec.decode(str, method) + self.arguments = arguments + super().__init__() -class ResponseFrame(NeovimFrame): +class ResponseFrame(IdentifiedFrame): kind = 1 - @classmethod - async def receive(cls, owner: NeovimEditing, payload: object) -> None: - identity, reply, value = FieldCodec.decode(tuple[int, NeovimReply, object], payload) - owner.resolve_reply(identity, reply, value) + def __init__(self, identity: object, reply: object, value: object) -> None: + super().__init__(identity) + self.reply = FieldCodec.decode(NeovimReply, reply) + self.value = value + + async def receive(self, owner: NeovimEditing) -> None: + owner.resolve_reply(self.identity, self.reply, self.value) -class NotificationFrame(NeovimFrame): +class NotificationFrame(MethodFrame): kind = 2 - @classmethod - async def receive(cls, owner: NeovimEditing, payload: object) -> None: - method, arguments = FieldCodec.decode(tuple[str, object], payload) - NeovimNotification.member_selected(method).receive(owner, arguments) + async def receive(self, owner: NeovimEditing) -> None: + NeovimNotification.member_selected(self.method).receive(owner, self.arguments) -class RequestFrame(NeovimFrame): +class RequestFrame(IdentifiedFrame, MethodFrame): kind = 0 - @classmethod - async def receive(cls, owner: NeovimEditing, payload: object) -> None: - identity, method, arguments = FieldCodec.decode(tuple[int, str, object], payload) - reply = NeovimHostRequest.member_selected(method).receive(owner, arguments) - await owner.answer_request(identity, reply) + async def receive(self, owner: NeovimEditing) -> None: + reply = NeovimHostRequest.member_selected(self.method).receive(owner, self.arguments) + await owner.answer_request(self.identity, reply) class NeovimEditing(VimMode): @@ -628,6 +701,7 @@ def __init__(self) -> None: self._rendered: NeovimSnapshot = UnpaintedSnapshot(self.snapshot.state) self._update_queued = False self._boundaries: list[Binding] = [] + self._api_types: dict[str, NeovimApiType] = {} @property def label(self) -> str: @@ -746,22 +820,58 @@ async def acquire_child() -> AttachedChild: request.reply.set_exception(error) self._pending.clear() + @cached_property + def _unpacker(self) -> msgpack.Unpacker: + return msgpack.Unpacker(raw=False, strict_map_key=False, + max_buffer_size=16 * 1024 * 1024, + ext_hook=self._decode_handle) + @boundary_decoder async def _read(self, child: AttachedChild) -> None: stream = child.stdout assert stream is not None - unpacker = msgpack.Unpacker(raw=False, strict_map_key=False, max_buffer_size=16 * 1024 * 1024) while chunk := await stream.read(65536): - unpacker.feed(chunk) - for message in unpacker: + self._unpacker.feed(chunk) + for message in self._unpacker: await self._receive(message) raise NeovimWireFailure(f"Neovim exited: {await child.stderr_tail()}") + def accept_api_info(self, response: object) -> NeovimApiInfo: + api = NeovimApiInfo.from_response(response) + self._api_types = api.types + return api + + @boundary_decoder + def _decode_handle(self, code: int, data: bytes) -> NeovimHandle: + try: + name = next(name for name, definition in self._api_types.items() + if definition.id == code) + member = NeovimHandle.member_named(name.casefold()) + except (StopIteration, UnknownFamilyMember) as error: + raise NeovimWireFailure(f"Unknown Neovim extension code {code}") from error + return member(FieldCodec.decode(int, msgpack.unpackb(data, raw=False))) + + @boundary_decoder + def _encode_handle(self, value: object) -> msgpack.ExtType: + if not isinstance(value, NeovimHandle): + raise NeovimWireFailure("Unsupported Neovim MessagePack value") + try: + return value.extension(self._api_types) + except KeyError as error: + raise NeovimWireFailure("Unknown Neovim extension type") from error + + async def write_frame(self, frame: Sequence[object]) -> None: + try: + await self._child.write(msgpack.packb( + frame, use_bin_type=True, default=self._encode_handle)) + except OSError as error: + raise NeovimWireFailure(str(error)) from error + async def _write_queued(self) -> None: await self._started while True: request = await self._outgoing.get() - await request.send(self._child) + await request.send(self) def _register(self, request: NeovimRequest) -> None: request.reply.add_done_callback(self._observe_failure) @@ -771,7 +881,7 @@ def _register(self, request: NeovimRequest) -> None: async def _bootstrap(self, request: NeovimRequest) -> object: self._register(request) - await request.send(self._child) + await request.send(self) return await request.result() def _enqueue(self, request: NeovimRequest) -> asyncio.Task[object]: @@ -800,6 +910,7 @@ async def _start(self) -> None: await self._child_ready editor = self._receiver.require() width, height = max(1, editor.wrap_width), max(3, editor.content_size.height + 2) + api = self.accept_api_info(await self._bootstrap(NeovimCall("nvim_get_api_info", []))) await self._bootstrap(NeovimCall("nvim_ui_attach", [width, height, {"ext_linegrid": True, "ext_cmdline": True, "ext_messages": True}])) initial_lines = self._initial_lines @@ -818,7 +929,7 @@ async def _start(self) -> None: index = len(self._boundaries) self._boundaries.append(binding) mappings.append([self.input_code(spelling), self.input_code(native), index]) - await self._bootstrap(NeovimCall("nvim_exec_lua", [script, [mappings]])) + await self._bootstrap(NeovimCall("nvim_exec_lua", [script, [mappings, api.channel]])) await self._bootstrap(NeovimCall("nvim_set_option_value", ["tabstop", editor.indent_width, {}])) if self.initial_keys: await self._bootstrap(NeovimInput(self._next_generation(), self.initial_keys)) @@ -831,7 +942,8 @@ async def _receive(self, message: object) -> None: raise NeovimWireFailure("Invalid Neovim RPC message") kind = FieldCodec.decode(int, message[0]) try: - await NeovimFrame.member_selected(kind).receive(self, message[1:]) + frame = NeovimFrame.member_selected(kind).from_payload(message[1:]) + await frame.receive(self) except UnknownFamilyMember as error: raise NeovimWireFailure(str(error)) from error @@ -852,7 +964,7 @@ def engine_boundary(self, boundary: int, display: NeovimDisplay) -> bool: return self._receiver.boundary(self._boundaries[boundary]) async def answer_request(self, identity: int, reply: object) -> None: - await self._child.write(msgpack.packb([1, identity, None, reply])) + await self.write_frame([1, identity, None, reply]) def accept_lines(self, change: NeovimLineChange) -> None: if change.last == -1: diff --git a/tests/textual/test_neovim_wire.py b/tests/textual/test_neovim_wire.py new file mode 100644 index 000000000..ab99e57c5 --- /dev/null +++ b/tests/textual/test_neovim_wire.py @@ -0,0 +1,50 @@ +"""The retained native win_viewport handle must survive the actual RPC decoder.""" + +import msgpack +import pytest + +from toad.neovim_editing import ( + BufferHandle, + NeovimCall, + NeovimEditing, + NeovimWireFailure, + TabpageHandle, + WindowHandle, +) + + +@pytest.mark.asyncio +async def test_native_redraw_and_response_keep_nominal_window() -> None: + owner = NeovimEditing() + owner.accept_api_info([1, {"types": { + "Buffer": {"id": 0, "prefix": "nvim_buf_"}, + "Window": {"id": 1, "prefix": "nvim_win_"}, + "Tabpage": {"id": 2, "prefix": "nvim_tabpage_"}, + }}]) + # Root's real nvim 0.12.4 first redraw carried this extension in win_viewport. + native_window = msgpack.ExtType(1, b"\xcd\x03\xe8") + owner._unpacker.feed(msgpack.packb([ + 2, "redraw", [["win_viewport", [1, native_window, 0, 1, 0, 0, 1, 0]]], + ], use_bin_type=True)) + redraw, = owner._unpacker + window = redraw[2][0][1][1] + assert window == WindowHandle(1000) + assert window != BufferHandle(1000) + assert window != TabpageHandle(1000) + await owner._receive(redraw) + + request = NeovimCall("nvim_get_current_win", []) + owner._register(request) + owner._unpacker.feed(msgpack.packb([1, request.identity, None, native_window])) + response, = owner._unpacker + await owner._receive(response) + assert request.reply.result() == window + assert owner._encode_handle(window) == native_window + + with pytest.raises(NeovimWireFailure, match="arity"): + await owner._receive([2, "redraw"]) + owner._unpacker.feed(msgpack.packb([ + 2, "redraw", [msgpack.ExtType(127, msgpack.packb(1000))], + ])) + with pytest.raises(NeovimWireFailure, match="Unknown Neovim extension code"): + tuple(owner._unpacker) From c0cc090d051d03041eb95ea56f3b17991638e766 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:53:47 -0400 Subject: [PATCH 18/27] Retain main's hosted runner policy with first-failure diagnostics --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb3a432b0..6a0bcf8eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: suite: runs-on: ubuntu-latest # A hung test must turn red in minutes, not burn GitHub's 6-hour default. - timeout-minutes: 15 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: @@ -91,4 +91,4 @@ jobs: # xdist reports a collection error as a worker crash; collect alone first so it reads as one. - run: .venv/bin/pytest --collect-only -q # A test stuck past 120 s dumps every thread, so the red names where it hung. - - run: .venv/bin/pytest -n 2 --basetemp .artifacts/pilots -o faulthandler_timeout=120 -rfE -x -vv --tb=short + - run: .venv/bin/pytest -n auto --basetemp .artifacts/pilots -o faulthandler_timeout=120 -rfE -x -vv --tb=short From 5b00a024eac92b42d9f8c9714e711626e556f82b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:03:40 -0400 Subject: [PATCH 19/27] Use declared handle dispatch and metadata field decoding --- src/toad/neovim_editing.py | 41 +++++++++++++++++++++---------- tests/textual/test_neovim_wire.py | 1 - 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 38ac91b91..8bd7da2e4 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -16,6 +16,7 @@ from agent_comms.child_process import AttachedChild, join_retirement from agent_comms.declared_family import DeclaredFamily, UnknownFamilyMember from agent_comms.field_codec import FieldCodec, MalformedRecord, NullMember, SingleFieldValue, SubclassUnion, boundary_decoder +from metaclass_registry.mro_dispatch import MroDispatch, handles from textual.binding import Binding from textual.cursor_shape import BlinkingBar, CursorShape, SteadyBlock from textual.document._document import DocumentBase, Location, RangedEditResult, Selection @@ -86,10 +87,25 @@ class NeovimApiType: prefix: str +@dataclass(frozen=True) +class NeovimApiMetadata: + types: dict[str, NeovimApiType] + + @classmethod + @boundary_decoder + def from_response(cls, metadata: object) -> Self: + if not isinstance(metadata, dict): + raise NeovimWireFailure("Invalid Neovim API metadata") + # Decode the declared field through its existing binding. Unused native + # metadata stays opaque; no second record decoder or copied field key. + binding, = FieldCodec.argument_fields(cls) + return FieldCodec.construct(cls, binding.decoded_bound(metadata, {})) + + @dataclass(frozen=True) class NeovimApiInfo: channel: int - types: dict[str, NeovimApiType] + metadata: NeovimApiMetadata @classmethod @boundary_decoder @@ -97,14 +113,7 @@ def from_response(cls, response: object) -> Self: if not isinstance(response, list) or len(response) != 2: raise NeovimWireFailure("Invalid Neovim API information response") channel, metadata = response - if not isinstance(metadata, dict): - raise NeovimWireFailure("Invalid Neovim API metadata") - try: - definitions = metadata["types"] - except KeyError as error: - raise NeovimWireFailure("Neovim API metadata has no extension types") from error - return cls(FieldCodec.decode(int, channel), - FieldCodec.decode(dict[str, NeovimApiType], definitions)) + return cls(FieldCodec.decode(int, channel), NeovimApiMetadata.from_response(metadata)) @dataclass(frozen=True) @@ -686,7 +695,7 @@ async def receive(self, owner: NeovimEditing) -> None: await owner.answer_request(self.identity, reply) -class NeovimEditing(VimMode): +class NeovimEditing(VimMode, MroDispatch): """One engine, one input queue, completed snapshots only from SafeState.""" initial_keys = "" @@ -838,7 +847,7 @@ async def _read(self, child: AttachedChild) -> None: def accept_api_info(self, response: object) -> NeovimApiInfo: api = NeovimApiInfo.from_response(response) - self._api_types = api.types + self._api_types = api.metadata.types return api @boundary_decoder @@ -853,8 +862,14 @@ def _decode_handle(self, code: int, data: bytes) -> NeovimHandle: @boundary_decoder def _encode_handle(self, value: object) -> msgpack.ExtType: - if not isinstance(value, NeovimHandle): - raise NeovimWireFailure("Unsupported Neovim MessagePack value") + try: + encoder: Callable[[NeovimEditing, object], msgpack.ExtType] = self.handler_for(type(value)) + except StopIteration as error: + raise NeovimWireFailure("Unsupported Neovim MessagePack value") from error + return encoder(self, value) + + @handles(NeovimHandle) + def _native_handle(self, value: NeovimHandle) -> msgpack.ExtType: try: return value.extension(self._api_types) except KeyError as error: diff --git a/tests/textual/test_neovim_wire.py b/tests/textual/test_neovim_wire.py index ab99e57c5..bb1cbe5f0 100644 --- a/tests/textual/test_neovim_wire.py +++ b/tests/textual/test_neovim_wire.py @@ -13,7 +13,6 @@ ) -@pytest.mark.asyncio async def test_native_redraw_and_response_keep_nominal_window() -> None: owner = NeovimEditing() owner.accept_api_info([1, {"types": { From c040661006eda64c369195714032987b4b87f2ab Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:18:09 -0400 Subject: [PATCH 20/27] Refuse empty literal keys and expose Neovim failure causes --- src/toad/neovim_editing.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 8bd7da2e4..ed8fd89ba 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -17,6 +17,7 @@ from agent_comms.declared_family import DeclaredFamily, UnknownFamilyMember from agent_comms.field_codec import FieldCodec, MalformedRecord, NullMember, SingleFieldValue, SubclassUnion, boundary_decoder from metaclass_registry.mro_dispatch import MroDispatch, handles +from textual import log from textual.binding import Binding from textual.cursor_shape import BlinkingBar, CursorShape, SteadyBlock from textual.document._document import DocumentBase, Location, RangedEditResult, Selection @@ -286,7 +287,7 @@ def fail(self, error: EditingFailure) -> NeovimState: @property def label(self) -> str: - return "NEOVIM FAILED — draft preserved" + return f"NEOVIM FAILED — {self.error} — draft preserved" def raise_failure(self) -> None: raise self.error @@ -1044,6 +1045,7 @@ def _fail(self, error: EditingFailure) -> None: failed = previous.fail(error) if failed is previous: return + log.error("Neovim editing failed:", error) self.publish(NeovimSnapshot(failed)) def notify(editor: TextArea) -> None: editor.notify(f"{error}. The displayed draft is preserved. Input was not replayed.", @@ -1083,9 +1085,10 @@ def input_code(key: str, character: str = "") -> str: """Textual's foreign key vocabulary enters Neovim at this boundary.""" *modifiers, name = key.split("+") literal = character or key_to_character(name) or "" - if not modifiers and literal.isprintable(): + is_character = bool(literal) and literal.isprintable() + if not modifiers and is_character: return literal.replace("<", "") - base = literal if literal.isprintable() else NeovimKey.spelling(name) + base = literal if is_character else NeovimKey.spelling(name) base = base.replace("<", "LT") return "<" + "-".join([*(NeovimKey.member_named(part).notation for part in modifiers), base]) + ">" From ba5355096e783a31712e7a3cf3b626fd3b005d7d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:08:14 -0400 Subject: [PATCH 21/27] Stop idle completion feedback and invalidate paint after RPC completion --- src/toad/neovim_editing.lua | 8 ++++++-- src/toad/neovim_editing.py | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua index 45e5fa649..70c950416 100644 --- a/src/toad/neovim_editing.lua +++ b/src/toad/neovim_editing.lua @@ -81,8 +81,12 @@ api.nvim_create_autocmd({'ModeChanged', 'CursorMoved', 'CursorMovedI', 'TextChanged', 'TextChangedI', 'CmdlineChanged'}, {callback = schedule_publish}) api.nvim_create_autocmd('SafeState', {callback = function() - completed = generation - schedule_publish() + if completed ~= generation then + completed = generation + -- Do not queue work from the idle notification: running that work returns + -- to SafeState and would schedule the next idle notification forever. + publish() + end end}) vim.on_key(function() -- lua.txt: on_key runs BEFORE further processing. This invalidates a proof; diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index ed8fd89ba..7caa82178 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -970,6 +970,10 @@ def resolve_reply(self, identity: int, reply: NeovimReply, value: object) -> Non raise NeovimWireFailure("Unexpected Neovim RPC response") from error reply.resolve(request, value) del self._pending[identity] + # SafeState can arrive before the final RPC admission response. Its + # publication and that response must invalidate the same view owner. + if self.completed: + self.request_paint() def engine_boundary(self, boundary: int, display: NeovimDisplay) -> bool: if not 0 <= boundary < len(self._boundaries): @@ -1000,6 +1004,10 @@ def publish(self, snapshot: NeovimSnapshot) -> None: self.snapshot = snapshot if changes: self._receiver.finish_projection() + self.request_paint() + + def request_paint(self) -> None: + """One coalesced paint slot for display and RPC completion changes.""" if not self._update_queued: def post(editor: TextArea) -> None: self._update_queued = editor.post_message(NeovimUpdated(self)) From a6b434caad0e621aa3bb632f85b4468a77b6832c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:30:00 -0400 Subject: [PATCH 22/27] Separate Lua operations and own snapshot completion transitions --- src/toad/neovim_editing.lua | 4 +- src/toad/neovim_editing.py | 116 ++++++++++++++++++++++++++++-------- 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/src/toad/neovim_editing.lua b/src/toad/neovim_editing.lua index 70c950416..7cc7bebc6 100644 --- a/src/toad/neovim_editing.lua +++ b/src/toad/neovim_editing.lua @@ -115,10 +115,10 @@ function ToadEditor.input(serial, keys) completed = -1 return api.nvim_input(keys) end -function ToadEditor.call(serial, method, arguments) +function ToadEditor.call(serial, operation, arguments) generation = math.max(generation, serial) completed = -1 - local result = api[method](unpack(arguments)) + local result = operation(unpack(arguments)) schedule_publish() return result end diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 7caa82178..98d2e2ff0 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -217,6 +217,9 @@ def transferable(self) -> bool: def update(self, display: NeovimDisplay) -> NeovimState: return display.state() + def completion(self, completion: NeovimCompletion) -> NeovimCompletion: + return completion + def fail(self, error: EditingFailure) -> NeovimState: return FailedState(cursor=self.cursor, command_line=self.command_line, regions=self.regions, region_text=self.region_text, error=error) @@ -270,6 +273,9 @@ def allows_application_commands(self) -> bool: class StartingState(NeovimState): mode_name = "NEOVIM STARTING" + def snapshot(self) -> NeovimSnapshot: + return NeovimSnapshot(self, NotCompleted()) + @property def label(self) -> str: return self.mode_name @@ -282,6 +288,9 @@ class FailedState(NeovimState): def update(self, display: NeovimDisplay) -> NeovimState: return self + def completion(self, completion: NeovimCompletion) -> NeovimCompletion: + return NotCompleted() + def fail(self, error: EditingFailure) -> NeovimState: return self @@ -486,9 +495,20 @@ def arguments(self) -> Sequence[object]: class NeovimOperation(NeovimCall): """Programmatic mutation executes on the same engine and invalidates its proof.""" + operation: ClassVar[str] = "vim.api[operation]" + def __init__(self, generation: int, method: str, arguments: Sequence[object]) -> None: - super().__init__("nvim_exec_lua", ["return ToadEditor.call(...)", - [generation, method, list(arguments)]]) + super().__init__("nvim_exec_lua", [ + "local serial, operation, arguments = ...; " + f"return ToadEditor.call(serial, {self.operation}, arguments)", + [generation, method, list(arguments)], + ]) + + +class NeovimLuaOperation(NeovimOperation): + """Lua chunks are not API names: nvim_exec_lua itself is RPC-only.""" + + operation = "assert(loadstring(operation))" class NeovimInput(NeovimFastRequest): @@ -532,10 +552,55 @@ def apply(self, document: DocumentBase) -> RangedEditResult: return document.replace_lines(0, document.line_count, self.lines) +class NeovimCompletion(ABC): + @classmethod + @boundary_decoder + def from_native(cls, generation: int) -> NeovimCompletion: + if generation == -1: + return NotCompleted() + if generation < 0: + raise NeovimWireFailure("Invalid Neovim completed generation") + return CompletedGeneration(generation) + + @abstractmethod + def matches(self, generation: int) -> bool: ... + + +@dataclass(frozen=True) +class NotCompleted(NeovimCompletion): + def matches(self, generation: int) -> bool: + return False + + +@dataclass(frozen=True) +class CompletedGeneration(NeovimCompletion): + generation: int + + def matches(self, generation: int) -> bool: + return self.generation == generation + + @dataclass(frozen=True) class NeovimSnapshot: state: NeovimState - completed: int = -1 + completed: NeovimCompletion + + def with_lines(self) -> NeovimSnapshot: + return NeovimSnapshot(self.state, self.completed) + + def with_display(self, display: NeovimDisplay) -> NeovimSnapshot: + state = self.state.update(display) + return NeovimSnapshot(state, state.completion(NeovimCompletion.from_native(display.completed))) + + def with_boundary(self, display: NeovimDisplay) -> NeovimSnapshot: + # A mapping's in-command display is not a SafeState certificate. + return NeovimSnapshot(self.state.update(display), NotCompleted()) + + def with_failure(self, error: EditingFailure) -> NeovimSnapshot: + return NeovimSnapshot(self.state.fail(error), NotCompleted()) + + def unpainted(self) -> UnpaintedSnapshot: + return UnpaintedSnapshot(self.state, self.completed) def matches[Answer](self, current: NeovimState, answer: Callable[[NeovimState], Answer]) -> bool: @@ -706,9 +771,9 @@ def __init__(self) -> None: self._pending: dict[int, NeovimRequest] = {} self._outgoing: asyncio.Queue[NeovimRequest] = asyncio.Queue() self._generation = 0 - self.snapshot = NeovimSnapshot(StartingState()) + self.snapshot = StartingState().snapshot() self._changes: list[NeovimChange] = [] - self._rendered: NeovimSnapshot = UnpaintedSnapshot(self.snapshot.state) + self._rendered: NeovimSnapshot = self.snapshot.unpainted() self._update_queued = False self._boundaries: list[Binding] = [] self._api_types: dict[str, NeovimApiType] = {} @@ -720,7 +785,8 @@ def label(self) -> str: @property def completed(self) -> bool: - return self.snapshot.completed == self._generation and not self._pending + return (self.snapshot.completed.matches(self._generation) + and not self._pending and not self._changes) @property def accepts_text(self) -> bool: @@ -762,7 +828,7 @@ def attach(self, editor: TextArea) -> None: self._update_queued = False editor.history.clear() # Even the same receiver may have rebuilt its width-dependent view. - self._rendered = UnpaintedSnapshot(self._rendered.state) + self._rendered = self._rendered.unpainted() started = self._started if started.done() and not started.cancelled(): self.paint(editor) @@ -919,6 +985,9 @@ def _next_generation(self) -> int: def _call(self, method: str, arguments: Sequence[object]) -> asyncio.Future[object]: return self._enqueue(NeovimOperation(self._next_generation(), method, arguments)) + def _lua(self, code: str, arguments: Sequence[object]) -> asyncio.Future[object]: + return self._enqueue(NeovimLuaOperation(self._next_generation(), code, arguments)) + def _input(self, keys: str) -> asyncio.Future[object]: return self._enqueue(NeovimInput(self._next_generation(), keys)) @@ -949,7 +1018,8 @@ async def _start(self) -> None: await self._bootstrap(NeovimCall("nvim_set_option_value", ["tabstop", editor.indent_width, {}])) if self.initial_keys: await self._bootstrap(NeovimInput(self._next_generation(), self.initial_keys)) - await self._bootstrap(NeovimCall("nvim_exec_lua", ["return ToadEditor.cursor(...)", [initial_cursor]])) + await self._bootstrap(NeovimLuaOperation(self._next_generation(), + "return ToadEditor.cursor(...)", [initial_cursor])) @boundary_decoder async def _receive(self, message: object) -> None: @@ -978,8 +1048,7 @@ def resolve_reply(self, identity: int, reply: NeovimReply, value: object) -> Non def engine_boundary(self, boundary: int, display: NeovimDisplay) -> bool: if not 0 <= boundary < len(self._boundaries): raise NeovimWireFailure("Unknown Neovim cursor boundary") - # A mapping's in-command view cannot certify SafeState completion. - self.publish(NeovimSnapshot(self.snapshot.state.update(display))) + self.publish(self.snapshot.with_boundary(display)) self._receiver.visit(self.render) return self._receiver.boundary(self._boundaries[boundary]) @@ -991,10 +1060,10 @@ def accept_lines(self, change: NeovimLineChange) -> None: self._changes.append(NeovimBufferTransfer(change.lines)) else: self._changes.append(change) - self.snapshot = NeovimSnapshot(self.snapshot.state) + self.snapshot = self.snapshot.with_lines() def accept_display(self, display: NeovimDisplay) -> None: - self.publish(NeovimSnapshot(self.snapshot.state.update(display), display.completed)) + self.publish(self.snapshot.with_display(display)) def publish(self, snapshot: NeovimSnapshot) -> None: """Project preceding lines before exposing the display that describes them.""" @@ -1049,12 +1118,11 @@ def _observe_failure[Result](self, result: asyncio.Future[Result]) -> None: self._fail(error) def _fail(self, error: EditingFailure) -> None: - previous = self.snapshot.state - failed = previous.fail(error) - if failed is previous: + failed = self.snapshot.with_failure(error) + if failed.state is self.snapshot.state: return log.error("Neovim editing failed:", error) - self.publish(NeovimSnapshot(failed)) + self.publish(failed) def notify(editor: TextArea) -> None: editor.notify(f"{error}. The displayed draft is preserved. Input was not replayed.", title="Neovim editing failed", severity="error", timeout=0) @@ -1107,21 +1175,21 @@ def enter_text(self, editor: TextArea) -> None: self._input("i") def load_text(self, editor: TextArea, text: str) -> None: - self._call("nvim_exec_lua", ["return ToadEditor.load(...)", [text]]) + self._lua("return ToadEditor.load(...)", [text]) def edit(self, editor: TextArea, edit: Edit) -> None: - self._call("nvim_exec_lua", ["return ToadEditor.replace(...)", - [edit.top, edit.bottom, edit.text.split("\n"), edit.maintain_selection_offset]]) + self._lua("return ToadEditor.replace(...)", + [edit.top, edit.bottom, edit.text.split("\n"), edit.maintain_selection_offset]) def insert(self, editor: TextArea, text: str, point: InsertionPoint, maintain_selection_offset: bool) -> None: - self._call("nvim_exec_lua", ["return ToadEditor.insert(...)", - [text.split("\n"), point.engine_location(), maintain_selection_offset]]) + self._lua("return ToadEditor.insert(...)", + [text.split("\n"), point.engine_location(), maintain_selection_offset]) def clear(self, editor: TextArea) -> None: self.load_text(editor, "") def move_to_end(self, editor: TextArea) -> None: - self._call("nvim_exec_lua", ["return ToadEditor.cursor_end()", []]) + self._lua("return ToadEditor.cursor_end()", []) def paste(self, editor: TextArea, text: str) -> None: self._call("nvim_paste", [text, True, -1]) @@ -1135,9 +1203,9 @@ def redo(self, editor: TextArea) -> None: def request_selection(self, editor: TextArea, selection: Selection) -> Selection: if not selection.is_empty: self._input("") - self._call("nvim_exec_lua", ["return ToadEditor.select(...)", sorted(selection)]) + self._lua("return ToadEditor.select(...)", sorted(selection)) elif selection.end != self.snapshot.state.cursor: - self._call("nvim_exec_lua", ["return ToadEditor.cursor(...)", [selection.end]]) + self._lua("return ToadEditor.cursor(...)", [selection.end]) return editor.selection def selection_range(self, editor: TextArea) -> Selection: From 16964a0de31f0a7a38d327de3093aaeac9709cdb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:44:12 -0400 Subject: [PATCH 23/27] Send viewport resize through the RPC request owner --- src/toad/neovim_editing.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 98d2e2ff0..69c79773e 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -495,7 +495,7 @@ def arguments(self) -> Sequence[object]: class NeovimOperation(NeovimCall): """Programmatic mutation executes on the same engine and invalidates its proof.""" - operation: ClassVar[str] = "vim.api[operation]" + operation: ClassVar[str] = "assert(vim.api[operation], operation)" def __init__(self, generation: int, method: str, arguments: Sequence[object]) -> None: super().__init__("nvim_exec_lua", [ @@ -1169,7 +1169,10 @@ def input_code(key: str, character: str = "") -> str: return "<" + "-".join([*(NeovimKey.member_named(part).notation for part in modifiers), base]) + ">" def resize(self, editor: TextArea) -> None: - self._call("nvim_ui_try_resize", [max(1, editor.wrap_width), max(3, editor.content_size.height + 2)]) + # This UI API is RPC-only, like nvim_ui_attach during startup. A + # viewport resize is not a buffer edit and must not advance its generation. + self._enqueue(NeovimCall("nvim_ui_try_resize", + [max(1, editor.wrap_width), max(3, editor.content_size.height + 2)])) def enter_text(self, editor: TextArea) -> None: self._input("i") From 6060b5d55040827f94c5d5ef5699f027903976d0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:25:48 -0400 Subject: [PATCH 24/27] Name the final RPC response plainly --- src/toad/neovim_editing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toad/neovim_editing.py b/src/toad/neovim_editing.py index 69c79773e..0b5072520 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -1040,7 +1040,7 @@ def resolve_reply(self, identity: int, reply: NeovimReply, value: object) -> Non raise NeovimWireFailure("Unexpected Neovim RPC response") from error reply.resolve(request, value) del self._pending[identity] - # SafeState can arrive before the final RPC admission response. Its + # SafeState can arrive before the final RPC response. Its # publication and that response must invalidate the same view owner. if self.completed: self.request_paint() From 7300bc2c6ce8b47f7414a7b034dc670ee64a5847 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:41:16 -0400 Subject: [PATCH 25/27] Declare the Textual candidate and derive sibling refreshes --- .github/actions/core-from-main/action.yml | 15 ++++++++------- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/actions/core-from-main/action.yml b/.github/actions/core-from-main/action.yml index 3d260ebfc..4d2baacf6 100644 --- a/.github/actions/core-from-main/action.yml +++ b/.github/actions/core-from-main/action.yml @@ -1,8 +1,8 @@ name: Core from main description: >- - Refresh Core from its declared branch while preserving the candidate's locked - sibling dependencies. Sync that environment and check Core out at the resolved - commit, so verification does not silently replace a paired dependency candidate. + Toad tracks sibling branches declared in pyproject.toml. This refreshes those sources, + syncs the environment from that lock, and checks Core out at the commit the lock + resolved, so one run has one answer to which Core it runs. inputs: path: description: Where to check Core out @@ -18,13 +18,14 @@ runs: run: | python - <<'PY' import os, pathlib, subprocess, sys, tomllib - package_name = 'agent-comms' - subprocess.run(['uv', 'lock', '--python', sys.executable, - '--upgrade-package', package_name], check=True) + sources = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['tool']['uv']['sources'] + updates = [argument for name, source in sources.items() if 'branch' in source + for argument in ('--upgrade-package', name)] + subprocess.run(['uv', 'lock', '--python', sys.executable, *updates], check=True) subprocess.run(['uv', 'sync', '--python', sys.executable, '--locked', '--all-extras', '--no-editable'], check=True) package, = (p for p in tomllib.loads(pathlib.Path('uv.lock').read_text())['package'] - if p['name'] == package_name) + if p['name'] == 'agent-comms') url, commit = package['source']['git'].split('#') repository = url.split('?')[0].removeprefix('https://github.com/').removesuffix('.git') print(f'Core resolved from main: {repository}@{commit}') diff --git a/pyproject.toml b/pyproject.toml index 0c7948e4b..47844df1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ members = ["toad"] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = "main" } metaclass-registry = { git = "https://github.com/OpenHCSDev/metaclass-registry", branch = "main" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "7ba616bd55c6ee40f156def5cb86ba28b0c0e16b" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } diff --git a/uv.lock b/uv.lock index c9ec12e35..19caaf59b 100644 --- a/uv.lock +++ b/uv.lock @@ -290,7 +290,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?branch=main" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=7ba616bd55c6ee40f156def5cb86ba28b0c0e16b" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=95db444b536cba27081dd2c1496f71e25670a446" }, { name = "textual-image", extras = ["textual"], git = "https://github.com/OpenHCSDev/textual-image.git?rev=1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" }, { name = "textual-serve", specifier = "==1.1.3" }, From 04b1289ff55ab4060a107caf4f544193f9c5f321 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:41:05 -0400 Subject: [PATCH 26/27] Keep failed-session feedback in its existing disclosure --- src/toad/acp/agent_session.py | 4 +++- tests/app/test_agent_startup_failure.py | 4 +++- tests/textual/test_acp_log_link.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index c8bfed59d..c8cc30efe 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -110,7 +110,9 @@ class FailedSessionDisposition(UnavailableSessionDisposition): @property def status(self) -> SessionDisconnected: - return SessionDisconnected(self.failure.message) + return SessionDisconnected( + self.failure.message, details=tuple(self.failure.details.splitlines()) + ) async def present_failure(self, failure: AgentFail, view) -> None: if failure is self.failure: diff --git a/tests/app/test_agent_startup_failure.py b/tests/app/test_agent_startup_failure.py index a6bcca840..14a2d5487 100644 --- a/tests/app/test_agent_startup_failure.py +++ b/tests/app/test_agent_startup_failure.py @@ -17,6 +17,8 @@ async def test_a_process_that_exits_during_startup_is_reported(workspace): async def test_a_refused_handshake_is_reported_as_not_sent(workspace): # The refusing process stays attached: a later owner may accept the same connection. async with workspace.app(size=(139, 25), binding=AgentAttached(peer("refuses_initialize"))) as ui: - await ui.until_shows("AGENT_REFUSED_TO_START", seconds=20) + await ui.until_shows("Thread disconnected: Request failed", seconds=20) + await ui.click("Thread disconnected: Request failed") + await ui.until_shows("AGENT_REFUSED_TO_START") assert ui.shows("Not sent") assert not ui.agent_ready() diff --git a/tests/textual/test_acp_log_link.py b/tests/textual/test_acp_log_link.py index c16d9ec20..10623d299 100644 --- a/tests/textual/test_acp_log_link.py +++ b/tests/textual/test_acp_log_link.py @@ -26,7 +26,7 @@ async def shown_log(ui): async def test_the_log_link_highlights_under_the_pointer_and_opens_the_agents_log_in_one_tab(workspace): async with workspace.app(size=(139, 30), binding=AgentAttached(peer("refuses_initialize"))) as ui: app, pilot = ui.textual - await ui.until_shows("AGENT_REFUSED_TO_START", seconds=20) + await ui.until_shows(LINK, seconds=20) await ui.until(lambda: ui.shows(LINK) and ui.link_at(LINK) is not None, why="the log link drawn as a link") owner = ui.selected_tab cell = ui._cell_of(LINK, 0) From 90ae1d8fe3aa6eefbc31ac4c87ac8a1cbd5ccacc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:56:18 -0400 Subject: [PATCH 27/27] Track restored Textual main and report all suite failures --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a0bcf8eb..ac41d9c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,4 +91,4 @@ jobs: # xdist reports a collection error as a worker crash; collect alone first so it reads as one. - run: .venv/bin/pytest --collect-only -q # A test stuck past 120 s dumps every thread, so the red names where it hung. - - run: .venv/bin/pytest -n auto --basetemp .artifacts/pilots -o faulthandler_timeout=120 -rfE -x -vv --tb=short + - run: .venv/bin/pytest -n auto --basetemp .artifacts/pilots -o faulthandler_timeout=120 -rfE -vv --tb=short diff --git a/pyproject.toml b/pyproject.toml index 47844df1b..0c7948e4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ members = ["toad"] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = "main" } metaclass-registry = { git = "https://github.com/OpenHCSDev/metaclass-registry", branch = "main" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "7ba616bd55c6ee40f156def5cb86ba28b0c0e16b" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } diff --git a/uv.lock b/uv.lock index 19caaf59b..eb16acae4 100644 --- a/uv.lock +++ b/uv.lock @@ -290,7 +290,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=7ba616bd55c6ee40f156def5cb86ba28b0c0e16b" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?branch=main" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=95db444b536cba27081dd2c1496f71e25670a446" }, { name = "textual-image", extras = ["textual"], git = "https://github.com/OpenHCSDev/textual-image.git?rev=1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -1328,7 +1328,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#7ba616bd55c6ee40f156def5cb86ba28b0c0e16b" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#bea0d377b9bc414dc377f1ebcbb51405fc7b0e03" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" },