import type { RpcClient } from "@neoinkjs/rpc"; import type { Nvim } from "./index"; /** * The RPC method name the paste-forwarding autocmd fires via `rpcnotify`, * and the method the host filters for. Single source of truth so the * autocmd command and the subscriber (`render.ts`) can never drift apart — * mirrors `NEOINK_RESIZE_METHOD`/`NEOINK_INPUT_METHOD`. */ export const NEOINK_PASTE_METHOD = "neoink_paste"; /** * The two nvim events whose combination reliably fires exactly once (per * write) for both a Normal-mode paste (`TextChanged`, confirmed against a * real embedded nvim: `nvim_paste` never actually switches modes, so only * `TextChanged` fires) and an Insert-mode one (`TextChangedI` — nvim's own * bracketed-paste handling in a real attached terminal can land while the * cursor is in Insert mode, e.g. inside a text field a component renders; * headless `nvim_paste` in this package's own tests never exercises that * path, but the render buffer's mode is not otherwise constrained). Exported * so this autocmd's event list stays the single source of truth for exactly * which events can carry a paste notification, for any future caller that * needs to reason about them. */ export const NEOINK_PASTE_EVENTS: readonly string[] = ["TextChanged", "TextChangedI"]; /** * The Ex command the paste-forwarding autocmd runs: reads the WHOLE buffer * (`getline(1,'$')`), joins it into one `\n`-separated string, and forwards * it to the host as `neoink_paste`'s first argument, ALONGSIDE the cursor's * current position (`line('.')`, `col('.')` — 1-indexed line, 1-indexed * BYTE column) as the second and third arguments. * * The buffer content alone is the buffer's FULL content after whatever just * changed it — not just "the pasted text" — because Vimscript has no * primitive for "only the text this specific change inserted". A pure * content diff against what the host already knew was there a moment * before (no cursor info at all) is NOT enough to recover that substring * correctly in every case: when the pasted text shares characters with the * content immediately adjacent to where it landed, a content-only diff can * misattribute which characters belong to the paste (verified against a * real embedded nvim: pasting `"]x"` at the boundary inside `"x]"` — content * that overlaps the paste on both sides — a naive prefix/suffix diff * recovers the WRONG substring, `"x]"`, not the actually-pasted `"]x"`). * The cursor position is the fix: nvim's paste primitive always leaves the * cursor sitting ON the last character it just inserted (confirmed against * a real embedded nvim, including across a multi-line paste and a paste * ending in a multi-byte character), so it is unambiguous ground truth for * WHERE the insertion ends — `render.ts`'s subscriber uses it, together * with the buffer's overall length delta, to recover the pasted substring * exactly, with no reliance on content matching at all. */ function buildAutocmdCommand(channelId: number): string { // `col('.')` is Vimscript's 1-INDEXED byte column (`:help col()`: "The // first column is one") — subtracting 1 here reports the 0-indexed byte // offset `render.ts`'s `locateCharAtByteCol`/`flatCharOffset` expect, // matching the convention every OTHER 0-indexed byte column already // flowing through this codebase uses (e.g. `nvim_win_get_cursor`'s own // `col`, which `Nvim.setWinCursor`/`getWinCursor` already treat as // 0-indexed). return ( `call rpcnotify(${channelId}, '${NEOINK_PASTE_METHOD}', ` + `join(getline(1,'$'), "\\n"), line('.'), col('.') - 1)` ); } /** * Registers the buffer-scoped `TextChanged`/`TextChangedI` autocmd that * reports `buffer`'s full content to the host whenever it changes — Ink * parity: Ink's bracketed-paste mode (`\x1b[?2004h`) makes the terminal * deliver a whole paste as one chunk instead of simulated keypresses; nvim * has no stdin stream to bracket, so instead this autocmd observes the ONE * kind of buffer mutation that (a) a real terminal's native bracketed-paste * handling produces and (b) this package's own ``-mapped input capture * (`input.ts`) never does — paste bypasses keymaps entirely in both real and * embedded nvim (confirmed empirically: pasting via `nvim_paste` while * `input.ts`'s keymaps are installed fires no `neoink_input` notification at * all), so this is the one channel through which pasted text can ever reach * the buffer. Scoping to one buffer (rather than every buffer, the way * `installResizeAutocmd` is deliberately NOT buffer-scoped) matches * `installInputKeymaps`'s own per-buffer scoping. * * Returns the autocmd id, which `uninstallPasteAutocmd` accepts to tear it * back down (`render.ts` does this on unmount, mirroring the resize * autocmd's own install/uninstall pair). */ export async function installPasteAutocmd( nvim: Nvim, buffer: number, channelId: number, ): Promise { return nvim.createAutocmd(NEOINK_PASTE_EVENTS, buildAutocmdCommand(channelId), { buffer, }); } /** Removes a paste autocmd previously installed by `installPasteAutocmd`. */ export async function uninstallPasteAutocmd(nvim: Nvim, autocmdId: number): Promise { await nvim.deleteAutocmd(autocmdId); } /** * One `neoink_paste` notification's payload: the buffer's full post-change * content (NOT yet narrowed to just the pasted text — see * `buildAutocmdCommand`'s doc), plus the cursor position at the moment it * fired — 1-indexed line, 1-indexed BYTE column, exactly `line('.')`/ * `col('.')`'s own convention — that `render.ts`'s subscriber uses as * ground truth for where the paste's insertion ends. */ export interface PasteNotification { bufferText: string; cursorLine: number; cursorByteCol: number; } /** * Routes `neoink_paste` notifications on `client` to `handler`. Unlike * `subscribeInput`/`subscribeResize` (each a single scalar argument, so they * share `subscribeNotification`'s one-value narrowing), this notification * carries three positional arguments — `buildAutocmdCommand`'s `rpcnotify` * call — so it validates and repackages them into one `PasteNotification` * object itself rather than reusing that helper. Returns an unsubscribe * function. */ export function subscribePaste( client: RpcClient, handler: (notification: PasteNotification) => void, ): () => void { return client.onNotification((method, params) => { if (method !== NEOINK_PASTE_METHOD) return; const [bufferText, cursorLine, cursorByteCol] = params; if (typeof bufferText !== "string") return; if (typeof cursorLine !== "number") return; if (typeof cursorByteCol !== "number") return; handler({ bufferText, cursorLine, cursorByteCol }); }); }