@neoinkjs/plugin
v0.1.5
Published
Mount a neoink React app into a Neovim float/split/tab; includes the stdio host and Lua bootstrap.
Maintainers
Readme
@neoinkjs/plugin
Turn a neoink app into a Neovim plugin: one Lua command starts your Bun
process, one JS call mounts your component into a window, and both ends talk
over the RPC channel Neovim's own jobstart already gives you.
examples/plugin is a full working plugin built with this package. Install
it, run :NeoinkDemo, and read its source alongside this doc.
The jobstart-rpc model
A neoink plugin has two halves:
- Lua, loaded by your plugin manager, registers a user command.
- JS/TS ("the host"), a Bun process, holds your React component tree.
The command's callback calls require("neoink").start({ dir = ... }), which
runs vim.fn.jobstart(cmd, { rpc = true }) on your build output. rpc = true
is what makes this work: Neovim treats the job's stdin/stdout as a
msgpack-RPC channel instead of a plain pipe, so the spawned process can call
back into the same Neovim that spawned it. No socket, no second instance,
no separate connect step.
On the JS side, @neoinkjs/plugin's mount() calls connectStdio() (from
@neoinkjs/rpc) to pick up that same channel from the host's own
process.stdin/process.stdout, then does the buffer/window/keymap setup
examples/live.tsx does by hand and renders your component into it.
nvim (:YourCommand)
└─ jobstart(cmd, { rpc = true }) Lua: require("neoink").start
└─ host process (Bun)
└─ connectStdio() JS: @neoinkjs/rpc
└─ mount(<App/>) JS: @neoinkjs/plugin
└─ your component, rendered into a real nvim bufferNothing here is neoink-specific RPC. It's the same jobstart/rpc
mechanism any Neovim plugin can use to talk to an external process. neoink
just gives you a typed client and a one-call mount on top of it.
JS/TS API
mount(element, options?)
import { mount } from "@neoinkjs/plugin";
const handle = await mount(<App />, { style: "float" });
await handle.waitUntilExit();
handle.unmount();Connects to the host's own stdio (via connectStdio()), creates a scratch
buffer, shows it per options.style, wires up interactive input
(useInput works exactly as it does anywhere else in neoink), and renders
element into it. This is the entry point every plugin host calls: it's
what examples/plugin/host.tsx uses.
MountOptions:
| Option | Type | Default | Meaning |
|---|---|---|---|
| style | "float" \| "split" \| "tab" | "float" | How the scratch buffer is shown |
| width | number | derived | Float: window width. Split/tab: rendered content width |
| height | number | derived | Float: window height. Split: the split window's height. Unused by "tab" |
| row | number | centered | Float only: window's top row |
| col | number | centered | Float only: window's left column |
"float"opens a centered floating window (roughly 80%/60% of the editor by default), the same styleexamples/live.tsxuses."split"opens a horizontal split below the current window (nvim_open_win({ split: "below" }))."tab"opens a new tab page and shows the scratch buffer in its one window.
MountHandle:
waitUntilExit(): Promise<void>: resolves when the app exits, viauseApp().exit()inside your component or a caller's ownunmount(). Rejects if a component callsexit(error). Also resolves if the user closes the mount's window directly (:q,<C-w>c, ...) instead of your app's own quit key — a one-shotWinClosedautocmdmount()installs notifies the host either way, so this never hangs waiting on a window that no longer exists.unmount(): void: tears down the render, closes the window/tab, deletes the scratch buffer, and closes the RPC channel. Safe to call more than once or on a partially-set-up mount. This is not a full process teardown — it closes the nvim-side resources this mount owns, but the host's own Bun process keeps running until it exits on its own. That's why every example above ends withprocess.exit(0)afterawait handle.waitUntilExit()/handle.unmount(), mirroringexamples/plugin/host.tsx: without it, the host process would just sit there, connected to a channel with nothing left to do.
mountWith(channel, element, options?)
Same as mount, but takes an already-open Channel instead of calling
connectStdio() itself. mount() is just mountWith(connectStdio(), ...).
Use mountWith directly when you already have a Channel from somewhere
else: attachToNvim() or spawnEmbeddedNvim() from @neoinkjs/rpc, for
example in a test that drives a mount against a real embedded Neovim without
spawning a second host process (see
packages/plugin/test/mount.integration.test.tsx).
mountWith takes ownership of the channel for that mount's lifetime.
unmount() closes it. Don't reuse one Channel across multiple mountWith
calls, or one mount's teardown closes the channel out from under another,
still-live mount.
connectStdio() (from @neoinkjs/rpc)
The channel mount() uses under the hood. Reads msgpack-RPC off
process.stdin, writes it to process.stdout: the exact stream pair
jobstart(cmd, { rpc = true }) wires up for its spawned process. Same
Channel shape as attachToNvim() and spawnEmbeddedNvim(), so anything
written against one works against all three.
import { connectStdio } from "@neoinkjs/rpc";
const channel = connectStdio();
// channel.client is a full RpcClient, the same one `attachToNvim` returnsYou won't normally call this yourself; mount() already does it.
Lua API
require("neoink") (packages/plugin/lua/neoink/init.lua) is the Lua half:
starting and stopping the host job, and resolving what command to run it
with.
start(opts)
require("neoink").start({ dir = "/path/to/your/plugin" })opts:
dir(required): your plugin's root directory. Itsdist/holds the build outputstartlooks for.cmd(optional): an explicit command list. When given,startruns it as-is and skipsdist/lookup entirely.on_exit(optional): called when the host process exits (in addition tostart's own bookkeeping).
start is idempotent: if a job is already running, it returns that job's id
without spawning a second one. Call stop() first if you want a fresh
process.
It also registers a VimLeavePre autocmd (once, regardless of how many
times start runs) that calls stop(), so quitting Neovim doesn't leave an
orphaned host process behind.
stop()
require("neoink").stop()Stops the running host job, if any. Safe to call with nothing running, or more than once in a row.
Cmd resolution
When opts.cmd isn't given, start resolves a command from opts.dir:
<dir>/dist/host: a compiled binary (bun build --compile). Used directly if it exists, nobunneeded at runtime.- Otherwise,
bun run <dir>/dist/host.js: a plain JS bundle. This is the fallback for a dev checkout or an install that skipped the compile step.
This is the same convention examples/plugin's bun run build /
bun run build:js scripts produce output for. See below.
Write your own plugin
The shape is always the same four pieces. examples/plugin is this exact
layout, working end to end. Copy it as your starting point.
1. host.tsx: your app, mounted with mount():
import React from "react";
import { mount } from "@neoinkjs/plugin";
import { Box, Text } from "@neoinkjs/components";
function App() {
return (
<Box borderStyle="round" padding={1}>
<Text>hello from your plugin</Text>
</Box>
);
}
const handle = await mount(<App />);
await handle.waitUntilExit();
handle.unmount();
process.exit(0);2. lua/neoink/init.lua: vendor @neoinkjs/plugin's Lua bootstrap into your
own plugin.
require("neoink") (the start/stop API documented above) has to
actually be on Neovim's runtimepath for your users — it isn't pulled in
automatically just because your plugin depends on the @neoinkjs/plugin npm
package. Copy this repository's packages/plugin/lua/neoink/init.lua into
your own plugin's lua/neoink/init.lua. Any plugin manager (lazy.nvim,
packer, vim-plug, ...) already puts everything under a plugin's lua/
directory on runtimepath, so once it's vendored there, require("neoink")
resolves the normal way any Neovim plugin's Lua module does — no
package.path surgery needed.
(examples/plugin — this repo's own demo plugin — instead prepends
packages/plugin/lua to package.path by hand. That's an in-repo-only
shim so the example can run against this checkout's own, unpublished
@neoinkjs/plugin without a build/publish step; see
examples/plugin/README.md. Don't copy that shim into a real,
standalone plugin — vendor the file as described above instead.
create-neoink will automate this vendoring step in a future release; for
now, copy it by hand.)
3. plugin/your-plugin.lua: registers a command that starts it:
local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h")
vim.api.nvim_create_user_command("YourPlugin", function()
require("neoink").start({ dir = here })
end, {})4. Build it:
bun run build # dist/host compiled binary, no bun needed to run it
bun run build:js # dist/host.js plain JS bundle, run via `bun run`Ship build (or build:js) as your plugin manager's install/build step.
examples/plugin/README.md has a working lazy.nvim snippet. start
prefers the compiled binary and falls back to bun run dist/host.js
automatically, so shipping either output (or both) just works.
For the full picture: a real <App/> with useInput/useState, the exact
Lua file that resolves its own directory, package.json build scripts, and
an install snippet, read examples/plugin/host.tsx,
examples/plugin/plugin/neoink-demo.lua, and examples/plugin/README.md.
