feat(cursor): centralized cursor engine with focus-stealing prevention#19
Merged
StanAngeloff merged 7 commits intodevelopfrom Mar 12, 2026
Merged
feat(cursor): centralized cursor engine with focus-stealing prevention#19StanAngeloff merged 7 commits intodevelopfrom
StanAngeloff merged 7 commits intodevelopfrom
Conversation
Introduce `cursor.lua` as the single choke point for all cursor movement in `.chat` buffers. System-initiated moves are deferred until the user goes idle (tracked via a per-buffer uv timer reset on CursorMoved), then executed. User-initiated moves (force=true) execute immediately and clear any pending deferred move. An invisible extmark tracks deferred targets through buffer mutations. Adds buffer state fields (cursor_pending, cursor_idle_timer), setup() with autocmds and cleanup hooks, and full test coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace direct nvim_win_set_cursor calls across 6 modules with cursor.request_move(). User-initiated sends (keymaps) pass user_initiated=true which becomes force=true on the spinner cursor move. System-initiated moves (response complete, tool phases, autopilot, executor) use deferred (non-forced) moves that coalesce and execute only after the user goes idle. Wire cursor.setup() into init.lua for autocmds and cleanup hooks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rsor engine Navigation (]m/[m) and role marker colon completion now use cursor.request_move(force=true) — user-initiated moves that execute immediately and clear any pending deferred move. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…er_cursor All production call sites now use cursor.request_move(). The old functions are preserved with @deprecated annotations for any external consumers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… sites Every cursor.request_move() call now includes a reason string that flows through the entire lifecycle — request, coalescing, idle timer reset, execution, cancellation, and skip. This makes "why did my cursor jump?" diagnosable with a single log grep. Reasons by call site: spinner/user-send, spinner/autopilot, response-complete, phase1/pending-placeholder, phase2/pending-block, tool-result/result, tool-result/next, autopilot/pending-tool, nav/next-message, nav/prev-message, role-marker-completion Engine internals now log at debug level for notable events (force moves, deferred executions, cancellations, buffer-not-visible skips) and trace level for routine operations (coalescing, timer resets, clamping). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
f347ef3 to
d07fe97
Compare
Remove move_to_bottom, center_cursor, and set_cursor — all call sites were replaced by cursor.request_move() in the cursor engine refactor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces a centralized cursor engine (
lua/flemma/cursor.lua) that replaces all directnvim_win_set_cursorcalls across the codebase with a single choke point. This solves the focus-stealing problem during autopilot agent loops: when the assistant is autonomously executing tool calls, cursor jumps should not interrupt a user who has scrolled away to read earlier context.Problem
Before this change, cursor movement was scattered across 6+ modules (
core.lua,ui/init.lua,tools/executor.lua,autopilot.lua,navigation.lua,keymaps.lua), each callingnvim_win_set_cursordirectly. Every module made its own decisions about when and where to move the cursor, with no coordination. During autopilot loops (where Flemma automatically re-sends after tool execution), this meant the cursor would jump to the bottom of the buffer on every cycle — even if the user had intentionally scrolled up to review conversation history.Design
The cursor engine introduces two movement paths:
Force path (
force = true)Executes the cursor move immediately. Used for user-initiated actions where the expectation is that the view follows the action:
<CR>to send a message → cursor jumps to the loading spinner]]/[[→ cursor jumps to the target message@You:) → cursor moves to the new lineDeferred path (
force = false, the default)Queues the cursor move behind an idle timer instead of executing it immediately. The move only fires after the user stops interacting for
updatetimemilliseconds (Neovim's built-in idle threshold). Used for system-initiated movements during streaming and autopilot:If the user is actively moving their cursor (reading earlier messages, reviewing tool results), the idle timer keeps resetting and the deferred move never fires — the user stays where they are. Once they stop moving, the pending move executes and brings them to the latest content.
Extmark tracking
Deferred moves don't store a raw line number — they place an invisible extmark at the target position. As the buffer mutates (streaming text appended, tool results injected), the extmark shifts with the content. When the idle timer finally fires, the engine reads the extmark's current position, ensuring the cursor lands at the right place even after significant buffer changes since the move was queued.
Coalescing
Multiple deferred requests overwrite the same extmark (fixed
id = 1in a dedicated namespace). Only the most recent target matters — earlier queued moves are silently superseded. This is critical during autopilot loops where several "move to bottom" requests may queue up in rapid succession.User-initiated threading
A new
user_initiatedboolean is threaded through the call chain:keymaps.lua→core.send_or_execute()→advance_phase2()→send_to_provider()→ui.start_loading_spinner(). This allows the spinner's cursor move to know whether the human pressed a key or the autopilot triggered a re-send, and choose force vs deferred accordingly.Reason-tagged logging
Every
request_movecall includes areasonstring that flows through the entire lifecycle — from initial request through coalescing, idle timer reset, and final execution or cancellation. This makes cursor movement fully diagnosable via:Flemma log:Reason tags used across the codebase:
response-complete— after assistant finishes respondingphase1/pending-placeholder— cursor to first tool approval placeholderphase2/pending-block— cursor to pending tool result blockspinner/user-send(force) vsspinner/autopilot(deferred)tool-result/<mode>— after tool result injectionautopilot/pending-tool— autopilot jumping to next pending toolnav/next-message,nav/prev-message(force) — user navigationrole-marker-completion(force) — after@You:colon insertionChanges by file
lua/flemma/cursor.lualua/flemma/state.luacursor_pendingandcursor_idle_timerfields toBufferStatelua/flemma/init.luacursor.setup()call during plugin initializationlua/flemma/core.luacursor.request_move(); threadsuser_initiatedthrough the send chainlua/flemma/ui/init.luastart_loading_spinnerto acceptspinner_optswithforceflag; routes through cursor engine; deprecatedmove_to_bottom,center_cursor,set_cursorlua/flemma/tools/executor.luamove_cursor_after_resultwithcursor.request_move()lua/flemma/autopilot.luacursor.request_move()lua/flemma/navigation.luacursor.request_move()lua/flemma/keymaps.luauser_initiated: trueto send actions; role-marker completion uses forcecursor.request_move()tests/flemma/cursor_spec.lua.changeset/cursor-engine.mdTest plan
make qapasses (luacheck, LuaLS type checking, import lint, all tests).chatfile, send a message, verify cursor follows to spinner (force path)]]/[[navigation still works (force path):Flemma logshows reason-tagged cursor movement entries🤖 Generated with Claude Code