Talk to pi from Neovim. Hit <leader>ai, type your question with placeholders like @this or @selection to include code context — pi streams its response in the TUI.
No copy-pasting. No context switching. Just ask.
:PiBridge fix @this " send current line
:PiBridge explain @selection " send visual selection
:PiBridge fix these @diagnostics " send LSP errors
:PiBridge review @buffer " send entire filepi auto-launches in a split if it's not running. Buffers auto-refresh from disk when the agent finishes. Works per-project — each directory gets its own pi instance.
Pairs with pi-bridge.ext (the pi extension that receives messages).
Requires pi-bridge.ext installed in pi (see pi-bridge.ext install).
Pin to releases (recommended):
{
"junkfactory/pi-bridge.nvim",
version = "*", -- latest tagged release
cmd = "PiBridge",
keys = {
{ "<leader>ai", mode = { "n", "v" }, desc = "Ask pi" },
},
opts = {
-- see Setup section for all options
},
}Or track main (may encounter instability):
{
"junkfactory/pi-bridge.nvim",
-- no version key; tracks main
cmd = "PiBridge",
keys = {
{ "<leader>ai", mode = { "n", "v" }, desc = "Ask pi" },
},
opts = {},
}lazy.nvim automatically calls require("pi-bridge").setup(opts) — no config = function() needed.
Pin to a release (recommended):
mkdir -p ~/.local/share/nvim/site/pack/plugins/start
git clone --branch v0.1.0 --depth 1 https://github.com/junkfactory/pi-bridge.nvim.git \
~/.local/share/nvim/site/pack/plugins/start/pi-bridge.nvimOr track main (may encounter instability):
mkdir -p ~/.local/share/nvim/site/pack/plugins/start
git clone https://github.com/junkfactory/pi-bridge.nvim.git \
~/.local/share/nvim/site/pack/plugins/start/pi-bridge.nvimRestart nvim after cloning. The plugin loads automatically.
Clone the repo and point your plugin manager at it. Edits under lua/ are picked up on the next :source or nvim restart.
lazy.nvim — use dir so lazy loads the local checkout instead of fetching from GitHub:
{
dir = "/absolute/path/to/pi-bridge.nvim",
name = "pi-bridge.nvim", -- preserve the plugin name for lazy's bookkeeping
opts = {
-- see Setup section for all options
},
}Run :Lazy install once to register; subsequent edits in the clone take effect after restarting nvim or running :Lazy reload pi-bridge.nvim.
Native packages (no plugin manager) — symlink the clone into pack/local/start so Neovim auto-loads it on startup:
mkdir -p ~/.local/share/nvim/site/pack/local/start
ln -s /absolute/path/to/pi-bridge.nvim \
~/.local/share/nvim/site/pack/local/start/pi-bridge.nvimRestart nvim after editing files under lua/, plugin/, or doc/ so Neovim re-scans the runtimepath. The symlink stays in sync with your working tree automatically.
Each repo (pi-bridge.nvim, pi-bridge.ext) is released independently. The exception is a socket protocol change — both repos are then tagged at the same version.
Releases are triggered by tagging. The tag.sh script handles validation, build checks, tagging, and pushing:
./.github/ci/tag.sh 0.1.2 # no 'v' prefix — script adds itThis runs the full test suite (make test), creates a v0.1.2 jj tag on main, and pushes. The push triggers a CI job that creates the GitHub release with auto-generated notes.
After both releases exist, a daily CI job appends a pairing line (e.g. "Requires pi-bridge.ext v0.1.2") to each release's notes.
DRY_RUN=1 ./.github/ci/tag.sh 0.1.2Runs checks and prints the tag/push commands without mutating anything.
Prefer version = "*" while pre-1.0 — semver guarantees are soft before 1.0, so tracking the latest tag is safer than pinning.
| Mode | Mapping | Action |
|---|---|---|
| Normal | <leader>ai |
Prompt for a message |
| Visual | <leader>ai |
Prompt with selection as context |
Or use the command:
:PiBridge " prompt for message
:PiBridge hello world " send message directlyAll responses render in pi's TUI — streaming text, tool calls, diffs, etc.
Include context directly in your message using placeholders:
| Placeholder | Replaces with | Example |
|---|---|---|
@this |
Current line as a fenced code block (filetype language) | ```lua local x = 1 ``` |
@selection |
Visual selection as a fenced code block (empty in normal mode) | Fenced selection text |
@buffer |
Absolute path to current buffer | /path/to/file.lua |
@buffers |
Newline-separated list of open buffer paths | /path/a.lua\n/path/b.lua |
@content |
Buffer content as a fenced code block (truncated at ~900KB if large) | Full buffer contents |
@diagnostics |
LSP diagnostics for current buffer | L1:C1 [ERROR] unused variable |
@marks |
One block per set letter mark (A-Z global + a-z buffer-local; digits excluded as auto-managed; literal @marks when none) |
One block per mark |
@mN |
Block for mark N (A-Z global; a-z, 0-9 buffer-local; literal when unset) |
Block for mark A |
Examples:
:PiBridge fix @this
:PiBridge explain @selection
:PiBridge fix these @diagnostics
:PiBridge refactor @this and check @diagnostics
:PiBridge review @buffer
:PiBridge summarize @content
:PiBridge compare @buffers
:PiBridge explain @mAUnknown @tokens pass through unchanged. Typing @ in the prompt shows autocomplete suggestions.
@this and @selection also report their line range to pi, which appears on the File: header link as File: [main.lua:12-200](/path/to/main.lua). @content is not ranged.
Each mark renders as a single line: a Vim mark X - [basename:LINE](/abs/path): header mirroring the File: link shape (clickable in pi's TUI), followed by the mark's line as an inline code span — truncated at 200 characters, since marks are context rather than code to edit. @marks covers letter marks across all listed buffers (globals A-Z
first, then each buffer's locals); every mark it lists can also be
addressed individually with @mN. @mN with an unset mark, and @marks
with no marks set, stay literal. Mark placeholders do not report a line
range.
require("pi-bridge").setup({
-- Split direction: "vertical" or "horizontal"
split_direction = "vertical",
-- Split size (percentage or absolute lines/cols)
-- nil = use Neovim defaults (50%)
split_size = nil, -- e.g., 80 for 80 cols vertical, 20 for 20 lines horizontal
-- Auto-launch pi if socket missing/invalid
auto_launch = true,
-- Max seconds to wait for socket after launching pi
launch_timeout = 10,
-- Command to launch pi (allows custom args, env, etc.)
launch_cmd = { "pi" },
-- Keymaps (set to false to disable default keymap)
keymaps = {
prompt = "<leader>ai", -- normal + visual mode
},
-- Log level: "trace" | "debug" | "info" | "warn" | "error"
log_level = "info",
-- Edit approval prompt: when pi is about to edit/write a file,
-- Neovim shows a y/a/n picker (vim.ui.select) — the diff itself
-- renders in pi's TUI. Set false to opt out (pi falls back to its
-- own TUI overlay because no approval_ack arrives within 1s).
edit_approval_prompt = true,
-- UI prompt mirror: mirror any pi extension's blocking prompt
-- (select / confirm / custom `ctx.ui.custom` components) into
-- Neovim during nvim-originated turns. Set false to opt out (the
-- ext side keeps wrapping, but nvim never sends mirror_ready so
-- every prompt stays in pi's TUI). See pi-bridge.ext's
-- PI_BRIDGE_UI_PROMPT_MIRROR env var for the other direction.
ui_prompt_mirror = true,
-- Send/receive notifications: show a toast when pi starts/finishes
-- working. Set false to silence (on base Neovim toasts require
-- Enter to dismiss). Errors and disconnect warnings are always shown.
notify = true,
})Remap or disable:
-- Custom key
keymaps = { prompt = "<leader>p" },
-- Disable defaults (use :PiBridge command)
keymaps = false,┌─────────────┐ Unix Socket ┌─────────────────┐
│ pi (TUI) │◄──────────────────► │ pi-bridge.nvim │
│ + extension│ JSON msgs │ (Lua) │
└─────────────┘ └─────────────────┘
On <leader>ai:
- Get
cwdfromvim.fn.getcwd() - Walk from
cwdupward toward$HOME, probing each directory for an active socket - The socket path is
~/.pi/agent/pi-bridge/sockets/<sha256(dir)>.sock— same hash algorithm as pi-bridge.ext - On no hit → auto-launch pi (if enabled)
The upward walk means opening Neovim in src/foo/bar/ finds the pi instance running at the project root without requiring :cd first. The walk stops at $HOME and never probes filesystem root.
When the socket doesn't exist or connection fails:
- Open a split (configured direction)
- Run
piin a terminal buffer - Wait for socket to appear (polls every 200ms up to
launch_timeout) - Connect and send message
| Scenario | Behavior |
|---|---|
| Socket exists but connection refused | Relaunch |
| User closes pi split manually | Next prompt detects missing socket, relaunches |
| Pi exits unexpectedly | Socket disappears, next prompt triggers relaunch |
auto_launch = false |
Error: "pi-bridge socket not found. Launch pi manually." |
On setup(), warns if vim.o.autochdir is enabled — socket matching uses cwd, which autochdir changes per-file.
Sockets are keyed by sha256(cwd). Each project directory gets its own socket — there is no global singleton and no shared registry. The resolver walks from your current directory upward to $HOME to find the nearest active socket, which makes nested buffers work without configuration.
autochdir is not supported. Because autochdir changes cwd per-buffer, the socket you reach depends on which file is active rather than which project you intended. The plugin warns on setup() if it is enabled and :checkhealth pi-bridge flags it under a dedicated autochdir line.
For project switching, use:
:cd {path}— change cwd for the whole window:lcd {path}— change cwd for the current window only
Both work normally. Avoid autochdir and avoid changing cwd with autocommands that fire on every buffer change.
Health distinguishes between "is there a server" and "is Neovim connected to it". Two different things, two different reports:
| Output | Meaning |
|---|---|
OK Socket: connected |
Persistent Neovim connection is up. Sending prompts will work. |
INFO Socket available, Neovim not connected: <path> |
pi is running but Neovim hasn't connected yet. Run :PiBridge to connect. |
WARN Socket file present but unreachable: <path> (<reason>) |
Stale socket file from a previous crash. Remove it or relaunch pi. |
INFO Socket: not connected (no socket file in cwd) |
No server in this cwd. Auto-launch will start one if enabled. |
:checkhealth only inspects state; it never opens or closes the persistent connection.
When pi is about to apply its edit or write tool for a request that originated from Neovim, it sends an approval_request over the socket. Both surfaces then offer the same choice — Neovim's picker and a y / a / n prompt that replaces pi's input box — and whichever answers first wins. When pi answers (or the request settles for any reason), it broadcasts approval_resolved and the Neovim picker is dismissed automatically — no stale prompt waiting on an already-answered request. (Rationale: two answering surfaces with a lingering picker confused users; dismissal removes that.)
Edits you type directly into pi never prompt — only Neovim-sent requests are gated.
The picker follows your configured vim.ui.select (dressing, snacks, fzf-lua, ...). With the stock picker — which blocks in inputlist() and cannot be closed programmatically — pi-bridge shows its own minimal floating prompt (same choices, same keys) so dismissal still works.
| Choice | Decision | Effect |
|---|---|---|
y |
yes |
Approve this single tool call |
a |
all |
Approve all future edits to that file this session |
n |
no |
Reject the tool call (pi narrates the rejection) |
<Esc> |
no |
Dismissed picker — same as n |
An approval_ack is sent the moment the request arrives (kept for compatibility with older pi versions; current pi no longer requires it — there is no ack timeout).
If pi disconnects while the picker is open, the picker is dismissed with a pi disconnected message and no response is sent.
If the buffer for the target file is loaded and &modified, the prompt notes it — the diff is always computed from disk, so what you see is what pi will apply.
require("pi-bridge").setup({ edit_approval_prompt = false })With this set, nvim never opens the picker or acks; pi's own prompt becomes the sole decision surface for Neovim-sent edits.
approval_request, approval_resolved, approval_ack, and approval_response are new NDJSON message types. Both pi-bridge.nvim and pi-bridge.ext must be tagged at the same version when this protocol is in use — see Releasing for the paired-tag rule.
When a turn is started from Neovim (<leader>ai) and any pi extension raises a blocking prompt (ctx.ui.select / ctx.ui.confirm / a custom ctx.ui.custom component such as pi-permission-system's permission dialog), pi-bridge surfaces it in Neovim. select / confirm are fully answerable from either surface (first answer wins; the other cleans itself up); custom prompts are answered in pi — Neovim shows a passive notice with an Esc-to-abort escape hatch.
Turns typed directly into pi auto-pass-through (no mirror, no stall) — the mirror is origin-scoped exactly like the edit-approval gate.
| Kind | What appears in Neovim |
|---|---|
select |
vim.ui.select picker — full original labels as items, with a format_item that display-trims labels longer than 80 chars to first 77 chars + "...". The picker returns the full untrimmed label, so the value sent back to pi is never truncated. Plugin pickers (dressing, snacks, fzf-lua, ...) work untouched. The stock vim.ui.select (which blocks in inputlist() and cannot be dismissed programmatically) is replaced by a minimal owned float with numbered keys 1..9 + <Esc>. |
confirm |
Same as select, folded in as a 2-option picker (Yes / No). Cancelled sends cancelled: true and the ext side resolves to false (matches pi's own RPC semantics). |
custom |
A passive, focused, message-only notice float: "π pi needs your input — answer in the pi window". pi owns the dialog and its navigation completely; no dialog content or re-renders are mirrored, and no key is forwarded. The notice honors only <Esc>: it closes the notice and injects an Esc keypress into the pi-side component (ui_prompt_response {id, key = "\x1b"}), so the component's own Esc handler decides what abort means (e.g. deny for pi-permission-system). Any other key is swallowed while the notice is focused. If the dialog resolves in pi first, ui_prompt_resolved closes the notice and breaks the loop. |
mirror_ready is sent once per successful connect (persistent and post-launch). The ext side installs its wrappers onto ctx.ui only after receiving it; without it, every extension prompt goes straight to pi's TUI.
The ready flag is cleared on socket disconnect (and a new mirror_ready is required on reconnect). On session boundaries (session_start / session_before_switch / session_shutdown) the ext side drops any open mirror surface; nvim simply no-ops on a stale id.
- nvim disconnects mid-prompt → the pi-side surface stays open awaiting the user's answer; nvim never resolves the pending request
- pi disconnects mid-mirror → nvim dismisses its picker/float with a
pi disconnectedmessage and sends no response (same as the edit-approval flow)
require("pi-bridge").setup({ ui_prompt_mirror = false })With this set, nvim never sends mirror_ready; the ext side's wrappers pass through, and every extension's prompt is answered in pi's TUI. The other direction is the ext env var PI_BRIDGE_UI_PROMPT_MIRROR=0 (see pi-bridge.ext Kill Switches) — both kill switches are independent.
mirror_ready, ui_prompt_request, ui_prompt_response, and ui_prompt_resolved are new NDJSON message types. Both pi-bridge.nvim and pi-bridge.ext must be tagged at the same version when this protocol is in use — see Releasing for the paired-tag rule.
Logs to vim.fn.stdpath("log") .. "/pi-bridge.nvim.log" (resolves to ~/.local/state/nvim/log/pi-bridge.nvim.log on Linux).
- Socket connection attempts (success/failure, path)
- Auto-launch triggers
- Messages sent to pi (prompt + context summary)
- Events received from pi
make test # run all tests
make test-context # context module only
make test-placeholders # placeholders module only
make test-init # init module only
make test-resolve # socket resolver only
make test-health # :checkhealth only
make test-approval # edit approval prompt onlyRequires Neovim 0.12.5+ and mini.nvim (auto-fetched as a test dependency).
- pi-bridge.ext — Pi extension side
- Inspired by opencode.nvim.