Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
04e2b73
Embed real Neovim through TextArea editing ownership
trissim Oct 29, 2026
2a54b48
Keep Neovim input fast and project actual incremental buffer and sele…
trissim Oct 29, 2026
5993e10
Merge remote-tracking branch 'origin/main' into work/vim-engine--P928
trissim Oct 29, 2026
16b6166
Preserve engine cursor authority and fence draft submission and transfer
trissim Oct 29, 2026
98589fd
Merge remote-tracking branch 'origin/main' into work/vim-engine--P928
trissim Oct 29, 2026
1209c25
Admit Neovim keys and edits together without timing out pending Vim i…
trissim Oct 29, 2026
333b9ee
Start fast RPC timing at dispatch before transport backpressure
trissim Oct 29, 2026
f5020de
Merge remote-tracking branch 'origin/work/vim-engine--P928' into work…
trissim Oct 29, 2026
e008569
Refuse flush without completed input and keep operand delivery alive
trissim Oct 29, 2026
11d1677
Decode RPC outcomes through their declarations and own retained surfa…
trissim Oct 29, 2026
11386d3
Use module identity and direct editing capability imports
trissim Oct 29, 2026
e9c7917
Invalidate editor completion on asynchronous buffer changes
trissim Oct 29, 2026
4ed3cfe
Use the actual engine display at history boundaries
trissim Oct 29, 2026
4d4638f
Keep in-command history displays incomplete
trissim Oct 29, 2026
2af945c
Publish document projection before the engine display
trissim Oct 29, 2026
237bc5a
Repaint restored view resources on editor attachment
trissim Oct 29, 2026
b2d0218
Keep an unpainted baseline until the retained view is presented
trissim Oct 29, 2026
c29eaa1
Track Textual main with the merged Neovim editing capability
trissim Oct 29, 2026
d036dc5
Diff the debt ratchet from the PR's merge-base, not GitHub's stale ba…
trissim Oct 29, 2026
ca450e8
Merge remote-tracking branch 'origin/main' into work/vim-neovim--P928…
trissim Oct 29, 2026
89ac1bf
Decode native Neovim handles at the RPC boundary
trissim Oct 29, 2026
883960d
Merge remote-tracking branch 'origin/main' into work/vim-neovim--P928…
trissim Oct 29, 2026
c0cc090
Retain main's hosted runner policy with first-failure diagnostics
trissim Oct 29, 2026
5b00a02
Use declared handle dispatch and metadata field decoding
trissim Oct 29, 2026
c040661
Refuse empty literal keys and expose Neovim failure causes
trissim Oct 29, 2026
99ed9b3
Prepare current main integration without moving frozen P928
trissim Oct 29, 2026
ba53550
Stop idle completion feedback and invalidate paint after RPC completion
trissim Oct 29, 2026
a6b434c
Separate Lua operations and own snapshot completion transitions
trissim Oct 29, 2026
16964a0
Send viewport resize through the RPC request owner
trissim Oct 29, 2026
6060b5d
Name the final RPC response plainly
trissim Oct 29, 2026
7300bc2
Declare the Textual candidate and derive sibling refreshes
trissim Oct 29, 2026
eaad4b2
Merge remote-tracking branch 'origin/main' into work/vim-neovim--P928…
trissim Oct 30, 2026
04b1289
Keep failed-session feedback in its existing disclosure
trissim Oct 30, 2026
90ae1d8
Track restored Textual main and report all suite failures
trissim Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 auto --basetemp .artifacts/pilots -o faulthandler_timeout=120 -rfE
- run: .venv/bin/pytest -n auto --basetemp .artifacts/pilots -o faulthandler_timeout=120 -rfE -vv --tb=short
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies = [
"ui-theme",
"formbridge==0.2.0",
"objectstate==5.0.0",
"msgpack==1.2.2",
]

[tool.uv.workspace]
Expand Down
4 changes: 3 additions & 1 deletion src/toad/acp/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 15 additions & 18 deletions src/toad/key_modes.py
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 1 addition & 2 deletions src/toad/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (*(binding for _, binding in owner.binding_defaults(native_mode)),
*owner.editor_command_bindings(native_mode))
defaults = tuple(binding for _, binding in owner.binding_defaults(native_mode))
if not issubclass(owner, ModalScreen):
defaults = (*defaults, *application)
owned.append(defaults)
Expand Down
186 changes: 186 additions & 0 deletions src/toad/neovim_editing.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
-- Application integration, not a Vim interpreter. Neovim interprets every key.
local mappings, channel = ...
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.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)
local kind = ({v = 'v', V = 'V', ['\022'] = '\022',
s = 'v', S = 'V', ['\019'] = '\022'})[code]
local regions, selected = {}, ''
if kind then
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
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(channel, '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(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
api.nvim_buf_attach(buffer, false, {
on_lines = function(_, buf, tick, first, last, new_last)
if api.nvim_get_current_buf() == buf then
completed = -1
vim.rpcnotify(channel, '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)
completed = -1
entered(event.buf)
schedule_publish()
end})
entered(api.nvim_get_current_buf())
api.nvim_create_autocmd({'ModeChanged', 'CursorMoved', 'CursorMovedI',
'TextChanged', 'TextChangedI', 'CmdlineChanged'},
{callback = schedule_publish})
api.nvim_create_autocmd('SafeState', {callback = function()
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;
-- 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.
for _, mapping in ipairs(mappings) do
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(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})
end
end

ToadEditor = {}
function ToadEditor.input(serial, keys)
generation = math.max(generation, serial)
completed = -1
return api.nvim_input(keys)
end
function ToadEditor.call(serial, operation, arguments)
generation = math.max(generation, serial)
completed = -1
local result = operation(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})
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
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
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)
-- 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(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_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_row + #lines - 1
local column = #lines[#lines] + (#lines == 1 and top_byte or 0)
api.nvim_win_set_cursor(0, {row + 1, column})
end
end
Loading
Loading