pi-nvim-bridge

Bridge pi's autocomplete engine to an external $EDITOR (Neovim) over a local Unix socket.

Packages

Package details

extension

Install pi-nvim-bridge from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-nvim-bridge
Package
pi-nvim-bridge
Version
0.1.3
Published
Aug 2, 2026
Downloads
549/mo · 285/wk
Author
trmnl_cmdr
License
MIT
Types
extension
Size
136 KB
Dependencies
0 dependencies · 2 peers
Pi manifest JSON
{
  "extensions": [
    "./extension/pi-nvim-bridge.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-nvim-bridge

Bridge pi's in-prompt completion into the Neovim instance pi launches as $EDITOR.

The installable extension is named pi-nvim-bridge (what you see in pi list); the companion Neovim plugin is pi-bridge.nvim.

Demo:

https://github.com/user-attachments/assets/12ff8f75-4bf8-4b27-a41b-d001633ea256

This repository ships two components that work together:

  • pi-nvim-bridge (this README's focus — a pi extension) captures pi's live autocomplete engine and serves it over a local Unix socket to the external editor pi spawns.
  • pi-bridge.nvim (the companion Neovim plugin, shipped in this same repo at the root — see :help pi-bridge) connects to that socket and renders pi's completion inside Neovim: /commands, skill: templates, argument completions, @file references, and filesystem paths.

Acceptance is delegated back to pi's applyCompletion, so insertion behavior is byte-for-byte identical to the TUI. Both components are complete and shipped.

What it does

It brings pi's autocomplete into Neovim. When you launch your external editor from pi, it behaves just like pi's prompt box — same / slash commands, same skill: templates, same @file references and path suggestions, same argument completions. Whatever pi would suggest inline, you get here, inside Neovim.

The idea is simple: write your prompt in a real editor without giving up any of pi's autocomplete. You get a comfortable editing surface and the full set of completions pi already offers.

There is a second completion engine for !/!! "bash mode" lines: your real shell's own completion engine (fish/zsh/bash) completes shell commands, rendered in the same floating menu with a $ gutter marker. It defaults to the shell pi executes commands in (prefer = "pi" — bash unless you set pi's shellPath), so completions and execution agree when the bridge can read pi's execution shell; when it can't (non-bash $SHELL, PI_NVIM_SHELL unset), a one-time :messages notice and the PI_NVIM_SHELL env var close the gap rather than a zsh-only alias being suggested then failing under bash. See :help pi-bridge-shell (doc/pi-bridge-shell.txt, §3) for the full guide.

Note: the companion pi-bridge.nvim plugin is complete (its runtime files live at the repo root: lua/pi-bridge/, plugin/pi-bridge.lua, ftplugin/, doc/). The bridge and plugin work together to deliver pi-faithful completion in the external editor.

Prerequisites

  • pi with extension support — or the oh-my-pi fork (omp); the extension runs under either host (see Installation below).
  • Neovim ≥ 0.11 (0.12 verified) for the companion plugin — the exact-UTF-16 cursor conversion needs the 3-arg vim.str_utfindex overload added in 0.11; :checkhealth pi-bridge enforces the floor.
  • fd (optional) — enables fuzzy @file search. Without it @file silently returns nothing, but path completion (directory listing) still works.
  • The companion pi-bridge.nvim plugin (installed via lazy.nvim — see Installation).

Installation

# Preferred: install from git
pi install npm:pi-nvim-bridge

# Or from a local clone
git clone https://github.com/dabstractor/pi-nvim-bridge
cd pi-nvim-bridge
pi install .

Verify:

pi list          # should show "pi-nvim-bridge"

oh-my-pi (omp)

The extension also runs under the oh-my-pi fork (omp, Bun runtime) with zero code change: every @earendil-works/* import is import type-only (so omp's loader erases it at load), and omp reads the same "pi" manifest key as a fallback ((pkg.omp ?? pkg.pi).extensions).

omp plugin install npm:pi-nvim-bridge
omp plugin list        # should show "pi-nvim-bridge"
omp plugin doctor      # should report it healthy (✔ plugin:pi-nvim-bridge)

Prefer omp over pi only if you already run the oh-my-pi fork. The companion pi-bridge.nvim plugin install is identical under either host — it keys only on the PI_NVIM_BRIDGE env var the bridge advertises.

⚠️ Multi-file package — no single-file drop-in. This extension is composed of four interdependent .ts files (pi-nvim-bridge.ts, connection.ts, jsonl-reader.ts, protocol.ts). You cannot install it by copying one file into ~/.pi/agent/extensions/. The imported siblings would be unresolved. Install it as a pi package via one of the commands above.

Companion plugin (pi-bridge.nvim, same repo) — install with your plugin manager. lazy.nvim (note lazy = false so the VimEnter startup shim sources before activation):

{
  "dabstractor/pi-nvim-bridge",
  lazy = false,
  config = function() require("pi-bridge").setup({}) end,
}

The repo's nvim runtime files (lua/pi-bridge/, plugin/pi-bridge.lua, ftplugin/, doc/) live at the root, so the clone lands directly on runtimepathno dir/sub hack needed (and none would be portable: no Neovim plugin manager supports cloning a subdirectory as the rtp root). With any other manager, just ensure the repo root is on your &runtimepath. See :help pi-bridge (doc/pi-bridge.txt).

Configuration ($EDITOR)

Tell pi to use Neovim as its external editor. Any one of:

export EDITOR=nvim
# or
export VISUAL=nvim

…or in pi's settings.json (this takes precedence over the env vars):

{ "externalEditor": "nvim" }

Then in pi, press Ctrl+G (the app.editor.external keybinding) to open the external editor. The bridge advertises PI_NVIM_BRIDGE to the Neovim process pi spawns, which the companion plugin keys on.

Shell completion

!/!! bash-mode lines are completed by your real shell's completion engine (fish/zsh/bash), rendered in the same floating menu as pi's completions. It defaults to the shell pi executes commands in (prefer = "pi" — bash unless you set pi's shellPath), so a completion and the command that runs agree whenever the bridge can read pi's execution shell. If your $SHELL is zsh or fish and you have NOT set pi's shellPath, export PI_NVIM_SHELL=<that shell> so completion and execution both use it (a one-time :messages notice warns you; see :help pi-bridge-shell §3). See :help pi-bridge-shell (doc/pi-bridge-shell.txt) for the full table.

NotePI_NVIM_SHELL. Sets the shell pi executes !/!! in when the bridge can't read pi's shellPath (it can't today — no public API). With a non-bash $SHELL and PI_NVIM_SHELL unset, prefer="pi" completes with $SHELL while pi still runs bash — they may disagree. Export PI_NVIM_SHELL (or set pi's shellPath) to fix. See :help pi-bridge-shell (§3). It is an env var you set in the shell that launches pi — not a setup() option.

require("pi-bridge").setup({
  shell = {
    enabled            = true,              -- master switch
    prefer             = "pi",              -- "pi" | "shell" | "bash" | "/abs/path"
    drivers            = { fish = true, zsh = true, bash = true },
    warm_on_enter      = false,             -- spawn the daemon at VimEnter
    visual_cue         = "gutter",          -- "$ " prefix on shell items
    -- timeout_ms, startup_timeout_ms, debounce_ms, max_parse_failures also exist;
    -- see :help pi-bridge-shell-config for the full table.
  },
})

Optional startup optimization

A heavy daily-driver Neovim config (LSP servers, lazy-loaded plugins, plugin- manager boot) can add hundreds of milliseconds-to-seconds to every Ctrl+G launch. You can keep a tiny dedicated config that loads only pi-bridge.nvim so the external-editor round-trip feels instant. Two ways to opt in:

Recommended — the bridge opt-in (PI_NVIM_APPNAME):

export PI_NVIM_APPNAME=1

The extension sets NVIM_APPNAME inside pi's process for the session (so the pi-spawned Neovim boots from ~/.config/pi-bridge/ instead of ~/.config/nvim/) and restores your prior value on exit — so a global NVIM_APPNAME, if you have one, is left untouched. Value table:

PI_NVIM_APPNAME resulting NVIM_APPNAME
(unset) (feature off — nothing changes)
"", 1, true, yes, on (case-insensitive) pi-bridge (default)
any other non-empty string that literal appname

The minimal config (any of the opt-in paths above) — create ~/.config/pi-bridge/init.lua that loads only pi-bridge.nvim (stock Neovim, no plugin manager required):

-- ~/.config/pi-bridge/init.lua
require("pi-bridge").setup({})

Neovim starts cleanly even before you create this file (it just has no user config), so the opt-in is safe to enable first and configure at your leisure.

⚠️ The appname must be a simple directory name (no /). Neovim rejects an appname containing a path separator; we don't validate it in the bridge — nvim surfaces a clear error in the launched editor.

Manual alternative: users who prefer not to use the extension opt-in can export NVIM_APPNAME=pi-bridge in the shell that launches pi — the child editor inherits it the same way (note: unlike the bridge opt-in, this does not restore a prior value because pi never owned the var).

How it works

  1. Live-provider capture. The extension registers a pass-through AutocompleteProviderFactory with pi. The factory simply captures the current provider pi hands it — so the bridge serves the same completion the TUI uses, not a snapshot.
  2. Process-local discovery. pi spawns $EDITOR inheriting process.env (no env: override, stdio: "inherit"). The bridge writes a JSON descriptor to process.env.PI_NVIM_BRIDGE inside pi, so the child Neovim sees it.
  3. JSON-RPC over a Unix socket. The bridge speaks newline-delimited JSON-RPC with these methods: getSuggestions, applyCompletion, and shouldTriggerFileCompletion, plus hello (token handshake), ping, bye, and getCommands. Completion acceptance calls back into pi's own applyCompletion, guaranteeing identical insertion semantics.
┌─────────────────────── pi process ───────────────────────┐
│  bridge extension   live AutocompleteProvider (captured) │
│  PI_NVIM_BRIDGE = { transport, path, token, … }          │
│  Unix socket  (/tmp/pi-nvim-bridge-<pid>.sock, 0600)     │
└────────────────────────────┬─────────────────────────────┘
                  JSON-RPC over socket │ getSuggestions / applyCompletion
                                       ▼
┌─────────────────────── Neovim (pi-bridge.nvim) ──────────┐
│  pi-mode completion client ── connects to the socket ──┐ │
│  ┌─────────────────────────────────────────────────────┐ │
│  │ shell daemon manager (spawns ONE persistent child)  │ │
│  └────────────────┬────────────────────────────────────┘ │
└───────────────────┼──────────────────────────────────────┘
        local stdio pipes only (stdin/stdout/stderr)
                    ▼
┌─────────────────────── shell subshell ───────────────────┐
│  fish / zsh / bash driver · sources rc / completion libs │
│  completes `!`/`!!` lines → candidates back up the pipe  │
└──────────────────────────────────────────────────────────┘

The shell daemon is a child of the Neovim process over local pipes only; it never touches the pi-nvim-bridge socket and never sees PI_NVIM_BRIDGE or its token (see Security, and :help pi-bridge-shell §8 for the rc-sourcing trust model).

The PI_NVIM_BRIDGE environment variable

The descriptor is a single-line JSON object:

{
  "transport": "unix",
  "path": "/tmp/pi-nvim-bridge-<pid>.sock",
  "token": "<32-byte hex>",
  "pid": 12345,
  "cwd": "/your/project",
  "fdAvailable": true,
  "serverVersion": "0.1.0"
}

echo $PI_NVIM_BRIDGE shows nothing in your shell — this is expected. The variable is written to process.env inside the pi process and is only visible to the child $EDITOR pi spawns. It is never exported to your shell. This is the #1 source of confusion; it is not a bug. To inspect it from inside the launched Neovim: :lua print(vim.env.PI_NVIM_BRIDGE).

Troubleshooting

  • "I typed, then :q, and lost my prompt." pi only reads the temp file after the editor exits with status 0. The companion plugin autosaves the buffer on VimLeavePre when modified (autosave_on_exit, default true). To be explicit, quit with :x/ZZ.
  • "Completion doesn't appear in Neovim." Confirm the extension loaded (pi list shows pi-nvim-bridge), confirm EDITOR=nvim (or externalEditor in settings), and confirm the companion pi-bridge.nvim plugin is installed and that it gated itself on the presence of PI_NVIM_BRIDGE.
  • "@file finds nothing." Install fd. The bridge reports fdAvailable in hello; without fd, @file is empty but path completion (directory listing) still works.
  • "I ran /reload while the editor was open." The bridge re-captures the provider and re-advertises the descriptor; the open editor's existing connection stays valid, and a commandsChanged notification fires so it can refresh.
  • "Another extension's custom trigger (e.g. #issues) doesn't complete." Known limitation. The bridge captures the provider at its own registration time, so wrappers registered after it won't appear. The base provider — slash commands, skill:, templates, and paths — is always captured.
  • "Nothing happens in non-interactive mode (pi -p)." Correct: openExternalEditor is TUI-only, so the bridge no-ops when ctx.mode !== "tui".
  • "!/!! completions are bash-quality / missing." The default prefer = "pi" resolves the shell two ways depending on whether the bridge could read pi's execution shell:
    • You see zsh/fish completions that then FAIL under bash — the resolver fell back to $SHELL (non-bash $SHELL, PI_NVIM_SHELL unset). Export PI_NVIM_SHELL=<your shell> (or set pi's shellPath) so completion matches execution.
    • You want bash-quality forced off — set setup({ shell = { prefer = "shell" } }) to complete with $SHELL, or disable the bash driver (setup({ shell = { drivers = { bash = false } } })). Run :checkhealth pi-bridge (the "pi-bridge shell completion" section) and see :help pi-bridge-shell (§3).

Security

  • The Unix socket lives in os.tmpdir() with 0600 permissions.
  • A 32-byte random token prevents another local process from impersonating the editor. It is delivered via process.env (process-local, never on disk) and validated in the hello handshake.
  • The server rejects any method before a valid hello.
  • Never log or echo the token. Treat the PI_NVIM_BRIDGE descriptor as sensitive — don't paste it into bug reports or completion descriptions.

Development

This is a packaging-and-docs repository; the extension source lives under extension/.

# Type-check (portable, reproducible gate)
npm run typecheck
# …or directly:
npx tsc --noEmit -p extension/tsconfig.json

Tests use node:test with jiti (not vitest). Run a single suite:

JITI_REG=/home/dustin/.local/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/jiti/lib/jiti-register.mjs
node --import "$JITI_REG" extension/tests/bridge-env.test.ts
# Run all suites:
for f in extension/tests/*.test.ts; do
  node --import "$JITI_REG" "$f" >/dev/null 2>&1 || echo "FAIL: $f"
done

The JITI_REG path above is machine-specific (it points at pi's bundled jiti). Adjust it to your own pi install. This is why the test npm script is a pointer rather than a hardcoded command.

Repository layout:

pi-nvim-bridge/
├── package.json              # pi package manifest (pi.extensions → extension entry)
├── README.md                 # project README (extension + nvim plugin)
├── LICENSE
├── plugin/pi-bridge.lua      # VimEnter auto-activation shim   ┐
├── lua/pi-bridge/            # init/bridge/completion/menu/coords/health/  │ nvim plugin
│                             #   blink_source/cmp_source/notify/jsonlreader │ runtime files
├── ftplugin/pi-prompt.lua    # 9 buffer-local keymaps + ft opts + autocmds   │ at the REPO ROOT
├── doc/pi-bridge.txt         # the vimdoc (`:help pi-bridge`)               │ (portable install)
├── tests/                    # plenary specs + plenary-free smokes          ┘
└── extension/                # the pi-nvim-bridge pi extension (TypeScript)
    ├── pi-nvim-bridge.ts     # entry: default-export factory
    ├── connection.ts         # JSON-RPC server + dispatch + registry
    ├── jsonl-reader.ts       # newline-delimited JSON framing
    ├── protocol.ts           # type-only: descriptor + RPC envelopes
    ├── tsconfig.json
    └── tests/                # node:test + jiti suites

Why runtime files live at the root: every Neovim plugin manager (lazy.nvim, packer, vim-plug, mini.deps) clones the repo and adds its root to &runtimepath; none portably supports a subdirectory as the rtp root. The pi-nvim-bridge npm package is unaffected — package.json files scopes the published tarball to extension/*.ts + README + LICENSE, so the lua never enters npm.

LICENSE: the manifest declares "license": "MIT" and an LICENSE file is committed at the repo root.

Links

Releasing

Releases are tag-driven. The git tag is the single source of truth for the published version — you never hand-edit package.json's "version".

git tag v1.2.3
 git push origin v1.2.3

Pushing a v* tag triggers .github/workflows/release.yml, which:

  1. extracts the version from the tag (strips the leading v, validates it as semver),
  2. writes that version into package.json inside the ephemeral runner checkout only (npm version … --no-git-tag-version) — nothing is committed back to the repo,
  3. publishes pi-nvim-bridge to npm with provenance attestation, and
  4. opens a GitHub Release with auto-generated notes (prereleases are auto-marked when the version contains a -, e.g. v1.0.0-beta.1).

One-time setup: add an npm access token (Automation or Granular, with publish rights for pi-nvim-bridge) as the repo secret NPM_TOKEN. Provenance also relies on the workflow's id-token: write permission, which is already declared in the workflow. If your token type can't do provenance, set publishConfig.provenance to false in package.json (and drop --provenance from the publish step).

The repo's package.json "version" stays at a placeholder (currently 0.1.0) on purpose — it is always overridden at publish time by the tag.