A filesystem + OpenCode review panel for LazyVim — track what changed on disk and what the agent is touching, then open an isolated diff in one keystroke.
Features · Install · Configure · Screencast · Contributing
fs-review.nvim is a LazyVim-friendly Neovim plugin that gives you a single
floating panel to review what is happening on disk and what your local
OpenCode agent is doing in the current project.
It has two modes, both visible in the same panel:
- External changes — every file the filesystem watcher sees (your editor, another tool, an agent, a coworker in another IDE).
- OpenCode activity — every file OpenCode sessions read or modified for the current project directory, reconciled against the live SSE event stream.
Press <CR> on any line to open an isolated diff tab (HEAD vs.
worktree) without losing your editor layout. Press q to come back.
- 📂 Recursive filesystem watcher powered by
vim.uv.fs_event. - 🔀 Git diff view — tracks
git diff --numstatper file in real time. - 🤖 OpenCode activity view — parses
/session,/session/:id/messageand the SSE/api/eventstream to surface files the agent touched. - 🪟 Isolated review tabs —
HEADvs. worktree diff in a dedicated tab that survives closing the panel. - 📡 Project-scoped OpenCode server —
auto_servebootsopencode serve --port <port>if the local server isn't already up. - 🧭 Current activity row — shows the most recent read/modified action.
- 🔄 Reconciliation across processes — a periodic signature probe compares session state so refreshes survive process restarts.
- 🌐 EN / ES — every visible string is overridable.
- 🎨 Theme-aware highlights —
Title,Comment,DiagnosticInfo,Specialnamespaces.
| Tool | Version | Notes |
|---|---|---|
| Neovim | ≥ 0.10 | requires vim.uv, vim.system, vim.uri_encode |
| LazyVim | any recent | spec provided; works standalone too |
curl |
any | used for HTTP + SSE probes |
git |
≥ 2.20 | uses git diff --numstat and git show HEAD: |
opencode CLI |
≥ 1.18 | optional, only needed for OpenCode activity |
All requirements are present on a stock LazyVim install except opencode,
which the plugin will offer to install / boot on first use.
Add the plugin under lua/plugins/fs-review.lua:
return {
"overflow/fs-review.nvim",
lazy = false,
priority = 1000,
opts = {}, -- see Configuration
}{
"overflow/fs-review.nvim",
lazy = false,
priority = 1000,
config = function()
require("fs_review").setup({})
end,
}MiniDeps.add("overflow/fs-review.nvim")
vim.schedule(function() require("fs_review").setup({}) end)use {
"overflow/fs-review.nvim",
config = function() require("fs_review").setup({}) end,
}Tip: if you use
:Lazyto install, make sure the plugin folder is onruntimepathbefore Neovim reads~/.config/nvim/init.lua.
Default options (override via require('fs_review').setup({...})):
require("fs_review").setup({
language = "en", -- "en" | "es"
auto_serve = true, -- boot `opencode serve` when missing
opencode_port = nil, -- nil → 4096; respects vim.g.opencode_opts.server.port
opencode_reconcile_interval = 2500, -- ms between SSE-friendly reconciliation polls
strings = { -- any key overrides a locale string
en = {
-- full list: see lua/fs_review/init.lua
-- e.g. activity = "Agent activity",
},
es = {
-- e.g. activity = "Actividad del agente",
},
},
})| Option | Default | Description |
|---|---|---|
language |
"en" |
Locale for all visible strings. Falls back to "en" if missing. |
auto_serve |
true |
When the local OpenCode server is unreachable, run opencode serve --port <port> from the project root. |
opencode_port |
nil |
Explicit port. nil reads vim.g.opencode_opts.server.port, then falls back to 4096. |
opencode_reconcile_interval |
2500 |
Milliseconds between reconciliation probes while the panel is open. 0 disables reconciliation. |
strings.en.* / strings.es.* |
{} |
Per-locale overrides. Keys map 1:1 to defaults (see source). |
The per-call API is require('fs_review').setup({}). Calling setup again
is safe: it cleans up watchers, timers and jobs before reloading.
| Command | Mode | Description | Default panel keymap |
|---|---|---|---|
:FsWatchRestart |
git | Restart the recursive filesystem watcher from the current project root. | — |
:FsReviewGit |
git | Toggle the Git filesystem review panel (and flip into git mode if needed). | — |
:FsReviewToggle |
git | Alias for :FsReviewGit. |
— |
:FsReviewOpenCode |
opencode | Query /session for the current project and switch the panel into OpenCode activity mode. |
— |
:FsReviewOpenCodeToggle |
opencode | Toggle the OpenCode activity panel. | — |
:FsReviewOpenCodeStop |
opencode | Stop the local opencode serve job and the SSE stream. |
— |
:FsReviewShow [{path}] |
both | Open an isolated diff tab for {path} (defaults to the first entry). |
<CR> on a row |
:FsReviewClear |
both | Empty the in-memory review queue for the active mode. | — |
:FsReviewCloseAll |
both | Close the floating panel and the isolated review tab. | q on the panel |
Inside the panel:
<CR>— open the isolated diff tab for the entry under the cursor.d— same as<CR>but also close the panel.q— close the panel.
Inside the review tab:
q— close the review tab and return to the original layout.
Mappings are buffer-local; they do not leak into other buffers.
-
:FsReviewGitopens a 60-column panel at the bottom listing every file the watcher has seen since startup. The header readsExternal changes (N) — <CR> view diff · q close. -
Edit
src/a.jsoutside Neovim (or trigger:!touch src/a.jsfrom another shell). The row pulses with a fresh timestamp. -
Move the cursor onto
src/a.jsand press<CR>. A new tab opens with[FS Review HEAD]and[FS Review WORKTREE]buffers side-by-side, diff mode enabled. -
Back in the original tab, run
:FsReviewOpenCode. The panel flips to OpenCode activity:OpenCode activity — Connected · updated just now — <CR> modified diff · q close Current activity: Completed modification · src/a.js · just now Modified (3): src/a.js modified ×2 · just now [sess_8x…] src/b.js modified ×1 · 5m ago [sess_8x…] docs/index.md modified ×1 · 12m ago [sess_q1…] -
After the OpenCode server runs for a while the reconciliation probe detects session signature drift (e.g. a sibling session in another shell wrote to a file) and re-runs the query automatically.
Press :FsReviewCloseAll to return to your original layout.
The plugin has two modes:
git— passive. Watches the filesystem, snapshots files intostate.git_files, pollsgit diff --numstaton render.opencode— active. Boots the SSE event stream, runs/sessionqueries, parses per-session messages, reconciles via a signature probe.
setup() FsReviewGit
idle ──────────────► git mode ◄─────────────┐
│ │
start_watching() │
│ │
▼ │
panel_open ────────────────┘
│
FsReviewOpenCodeToggle
▼
opencode mode
│
load_opencode() → query → stream
│
event_force_stopped?
┌───────┴────────┐
yes no
│ │
cleanup() start_reconciliation()
start_event_stream(base, target)opens an SSEcurl -Njob againstbase .. "/api/event"with headerx-opencode-directory: <target>.- Each
data:line triggersschedule_opencode_refresh(target)(debounced to 2 seconds). start_reconciliation()runsvim.uv.new_timer()everyopencode_reconcile_intervalms. The probe computes a signature of(id, updated_at_kind, updated_at)for every selected session and callsload_opencode(target)only when the signature differs from the cached one.- A monotonic
opencode_requesttoken invalidates stale callbacks; areconciliation_probe_tokendoes the same for the probe job. If the probe fails, the next tick retries without flooding the user.
open_review(path)callsensure_review_layout()which records the caller's tab/window, openstabnew, thenvsplitfor the worktree side.- Buffers are
nofile,bufhidden=wipe,swapfile=false, and get a stable filename (fs-review://HEAD/<rel>andfs-review://WORKTREE/<rel>) so they survive BufWipeout cycles. close_review()restores the caller's tab/window via the storedreview_caller_tab/review_caller_winpair.
See docs/architecture.md for diagrams.
The plugin probes with curl --max-time 1 then falls back to a
/dev/tcp connect. If both fail:
- Confirm
opencode serve --port 4096works in your shell. - Check
vim.g.opencode_api_urlis not set to an external host. - Verify the project directory you opened contains a
.git/folder (the plugin uses it as the project root).
OpenCode scopes sessions by the directory the agent was launched in. The
plugin only shows sessions whose directory (or cwd) equals
find_project_root() or sits under it. If you started the agent from a
worktree or sub-directory, the session won't show up under a parent repo
— launch the agent from the project root, or open the worktree in a
separate Neovim instance.
The plugin walks parents looking for .git/. If you have a monorepo with
nested .git/ markers, anchor your session with vim.g.opencode_opts.server.port
or open the right subfolder.
Each panel registers a BufWipeout autocmd that calls
invalidate_opencode_load() and stop_reconciliation(). If you closed
the buffer with bwipe! and timers keep running, make sure the autocmd
fired (check :autocmd FsReview) — the autocmd uses the modern
{ buffer = ..., callback = function(args) ... end } shape; do not pass
a positional match argument or the callback will receive the wrong
arguments and silently fail (this is the Napkin rule).
OpenCode runs in two distinct modes:
opencode(TUI) — interactive shell. Does not expose the HTTP API.opencode serve --port <port>— background server exposing/session,/session/:id/messageand the/api/eventSSE stream.
The plugin only talks to the serve mode. If you started OpenCode from
the TUI, restart it with opencode serve --port 4096 & from your
project root.
nvim --headless -u NONE \
--cmd "set rtp+=/home/overflow/Code/fs-review.nvim" \
-c "lua require('fs_review').setup({ auto_serve = false })" \
-c "lua vim.cmd('FsReviewGit')" \
-c "qa!" \
&& echo OK- Monorepo activity: per-package scope (anchor by
package.json/pyproject.toml/go.modetc.). - Custom scopes: pass a closure that resolves a project root from
vim.api.nvim_buf_get_name(0). - Telemetry export to
oil.nvim-style floats.
Open a feature request if you want something on this list.
See CONTRIBUTING.md for the Conventional Commits table,
the PR template, and the napkin rule about light autocommand callbacks.
MIT © 2026 overflow.