Skip to content

feat(cursor): centralized cursor engine with focus-stealing prevention#19

Merged
StanAngeloff merged 7 commits intodevelopfrom
feature/cursor-engine
Mar 12, 2026
Merged

feat(cursor): centralized cursor engine with focus-stealing prevention#19
StanAngeloff merged 7 commits intodevelopfrom
feature/cursor-engine

Conversation

@StanAngeloff
Copy link
Collaborator

@StanAngeloff StanAngeloff commented Mar 9, 2026

Summary

Introduces a centralized cursor engine (lua/flemma/cursor.lua) that replaces all direct nvim_win_set_cursor calls 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 calling nvim_win_set_cursor directly. 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:

  • User presses <CR> to send a message → cursor jumps to the loading spinner
  • User navigates between messages with ]]/[[ → cursor jumps to the target message
  • User completes a role marker (@You:) → cursor moves to the new line

Deferred 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 updatetime milliseconds (Neovim's built-in idle threshold). Used for system-initiated movements during streaming and autopilot:

  • Spinner starts during autopilot re-send → move is deferred
  • Tool result is injected → move is deferred
  • Response completes → move is deferred

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 = 1 in 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_initiated boolean is threaded through the call chain: keymaps.luacore.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_move call includes a reason string 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:

cursor: deferred move queued (spinner/autopilot) → line 42 (bottom) +center, buf 3
cursor: idle timer reset (CursorMoved), pending (spinner/autopilot), buf 3
cursor: deferred move (response-complete) supersedes pending (spinner/autopilot), buf 3
cursor: executing deferred move (response-complete) → line 58 (bottom) +center, buf 3

Reason tags used across the codebase:

  • response-complete — after assistant finishes responding
  • phase1/pending-placeholder — cursor to first tool approval placeholder
  • phase2/pending-block — cursor to pending tool result block
  • spinner/user-send (force) vs spinner/autopilot (deferred)
  • tool-result/<mode> — after tool result injection
  • autopilot/pending-tool — autopilot jumping to next pending tool
  • nav/next-message, nav/prev-message (force) — user navigation
  • role-marker-completion (force) — after @You: colon insertion

Changes by file

File What changed
lua/flemma/cursor.lua New module. Force path, deferred path, extmark tracking, idle timer management, setup with autocmds, cleanup hooks, reason-tagged logging throughout
lua/flemma/state.lua Added cursor_pending and cursor_idle_timer fields to BufferState
lua/flemma/init.lua Added cursor.setup() call during plugin initialization
lua/flemma/core.lua Replaced 3 direct cursor moves with cursor.request_move(); threads user_initiated through the send chain
lua/flemma/ui/init.lua Changed start_loading_spinner to accept spinner_opts with force flag; routes through cursor engine; deprecated move_to_bottom, center_cursor, set_cursor
lua/flemma/tools/executor.lua Replaced direct cursor move in move_cursor_after_result with cursor.request_move()
lua/flemma/autopilot.lua Replaced pending-block cursor jump with cursor.request_move()
lua/flemma/navigation.lua Replaced next/prev message cursor moves with force cursor.request_move()
lua/flemma/keymaps.lua Added user_initiated: true to send actions; role-marker completion uses force cursor.request_move()
tests/flemma/cursor_spec.lua New. 8 tests: force moves, line clamping, bottom resolution, pending clearing on force, deferred extmark placement, extmark position tracking through insertions, coalescing, cancel_pending
.changeset/cursor-engine.md Minor bump changeset

Test plan

  • make qa passes (luacheck, LuaLS type checking, import lint, all tests)
  • 8 new cursor engine tests covering force/deferred paths, extmark tracking, coalescing, cancellation
  • Manual: open a .chat file, send a message, verify cursor follows to spinner (force path)
  • Manual: during autopilot loop, scroll up — verify cursor does NOT steal focus
  • Manual: stop scrolling, wait for idle — verify cursor catches up to latest content
  • Manual: ]]/[[ navigation still works (force path)
  • Manual: :Flemma log shows reason-tagged cursor movement entries

🤖 Generated with Claude Code

StanAngeloff and others added 6 commits March 12, 2026 19:35
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>
@StanAngeloff StanAngeloff force-pushed the feature/cursor-engine branch from f347ef3 to d07fe97 Compare March 12, 2026 17:38
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>
@StanAngeloff StanAngeloff merged commit b286801 into develop Mar 12, 2026
@StanAngeloff StanAngeloff deleted the feature/cursor-engine branch March 12, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant