pivi drives an external Pi process over JSONL RPC and mutates live buffers.
Both properties break the usual "require the module, assert side effects" pattern.
Follow these rules; they are load-bearing, not style.
Spec: .system/specs/NVIM-SPEC-GMWDCMJZ-pivi-native-neovim-integration/index.md.
Research (Pi 0.85.1 / nvim 0.12 API facts): .system/research/NVIM-RESEARCH-KZYYEFY_-pi-and-neovim-capabilities-for-pivi/index.md.
Test layers
Layer
Scope
Where
Runs
L1 pure
framing, delta assembly, status reducer, partial-arg parser, snapshots
tests/bugabinga/pivi/*_spec.lua
every just test
L2 fake transport
pivi.rpc fed scripted frames
tests/bugabinga/pivi/rpc_spec.lua
every just test
L3 editor effects
real buffers, extmarks, undo, qf/loclist, windows
tests/bugabinga/pivi/editor/*_spec.lua
every just test
L4 driven nvim
interactive/async behavior in a live nvim
tests/drive/*.lua
on demand
L5 Pi contract
real pi --mode rpc assumptions
tests/contract/*_spec.lua
opt-in only
L6 rendering
actual TUI pixels/layout
WezTerm pane capture
rare, manual
Most pivi code belongs in L1. If a bug can only be caught in L4+, the logic is in the wrong place.
Rules
Keep the core pure. Parsing, assembly, and state transitions are fn(state, frame) -> state, effects. No vim.api inside them.
Inject the spawner.pivi.rpc takes a spawn function; tests pass a fake. Never rawset(vim, 'system', ...) for pivi tests — pivi holds a long-lived process, and global monkeypatching leaks across specs.
Frame tests must include hostile input: records split at arbitrary byte boundaries, trailing \r, U+2028/U+2029 inside JSON strings, interleaved responses and events, unknown event types, stderr noise, child exit mid-stream.
Chunk-split fuzz is mandatory for the decoder: replay one golden transcript under many random split points and assert an identical frame sequence.
Never sleep. Use vim.wait(ms, predicate) or an RPC roundtrip as a flush barrier. A bare vim.wait(200) in a committed test is a bug.
Assert data, not screens, except in L6.
Prove scheduling discipline. Editor-facing callbacks assert not vim.in_fast_event(); RPC output callbacks run in fast contexts and must defer.
Ghost text must be provably inert: feed incomplete tool arguments, assert b:changedtick is unchanged.
Undo coherence is a test, not a hope: apply an edit, press u once, assert full revert; force the undo-join refusal path and assert the edit survives.
Namespace isolation is a test: agent diagnostics/extmarks in pivi's own namespace, LSP namespaces untouched.
Clean up in after_each: delete created buffers, clear namespaces, stop fake/real children, package.loaded['bugabinga.pivi.*'] = nil.
Consumers degrade: at least one spec asserts a statusline/consumer works with pivi never loaded, and that status evaluation performs no RPC.
Fixtures come from reality. Golden JSONL under tests/fixtures/pivi/ is recorded from a real pi --mode rpc run with the user's own Pi configuration loaded, never hand-written from memory.
Read them through tests/helpers/pivi_fixture.lua.
Fixture
Contents
rpc-no-llm.jsonl
responses only, no model call
rpc-provider-error.jsonl
a real failed run, provider error on message_end
rpc-text-stream.jsonl
a streamed answer, thinking and text deltas, extension traffic
Bring your own Pi. pivi adds -e <companion extension> and nothing else to the user's Pi.
Never pass --no-extensions, --no-skills, --no-prompt-templates, --no-context-files, --no-builtin-tools, or --system-prompt, in product code, in contract tests, or while recording fixtures.
:Pi help isolates session state only: --no-session, --no-tools, --append-system-prompt.
Specs assert this on both command builders; keep them.
A run started without the user's configuration proves nothing, and silently changes which providers exist.
Help context stays small and live.:Pi help attaches only the visible region of the current window plus layout, selection, recent messages, and the last error; never whole buffers.
Depth is fetched on demand: nvim_help for Neovim and plugin documentation, pi-knowledge for Pi, the pivi skill for pivi, nvim_state and nvim_lua for live values.
Never answer a Neovim documentation question from model memory in this codebase: nvim_help resolves against the running version's runtime path, including plugin docs.
Help is read-only by construction; keep mutating tools out of its allowlist.
Edits stay proportional. Never rewrite a whole buffer for a local change: edit.replace rewrites only the spanned lines and edit.set_content diffs first.
A whole-buffer replacement marks every line as agent work, destroys marks and folds, and makes the trail useless.
Assert the trail covers only the changed lines.
The trail belongs to the agent until the user edits.activity.touch records the agent-owned revision; a later foreign change clears the trail.
Anything the agent's own operation triggers, including a write, must call touch again, or an unrelated plugin reacting to the write wipes the trail.
A revision recorded before the operation finishes is not enough; re-record after the event loop settles.
The transcript buffer holds message text, nothing else. Framing, labels, quoting and tool markers are extmark decoration in bugabinga.pivi.render.
A spec must assert both that the framing is absent from the text and that it is present as decoration; otherwise the next change silently reintroduces prefixes.
Entry bounds are extmark-backed.bugabinga.pivi.entry re-anchors on every write, and an entry ends at its last non-empty row because appending a complete line always leaves a trailing empty row behind.
Never address an entry by a remembered line number.
Never force a parse per streamed delta. Markdown rendering is vim.treesitter.start and stays redraw-driven; a forced parse per delta measured roughly five hundred times slower.
Keep the streaming budget spec in tests/bugabinga/pivi/editor/render_spec.lua.
Never invent an outcome.bugabinga.pivi.tool derives everything from what a tool reported in its result details, and a tool that reported nothing gets no outcome, no language, and no change.
Pi reports a change on every edit, progress on bash, and truncation only at a limit; write reports nothing at all.
Every interpretation needs a spec for the reporting tool and a spec for the silent one.
Entry shape changes without the buffer changing. Re-anchoring an entry moves its bounds while touching no character, so anything cached per buffer must key on entry.version as well as b:changedtick.
A fold cache keyed on the tick alone silently goes stale the moment an entry is rewritten.
A hook only reaches buffers made after it.transcript.on_buffer, on_window and on_reset listeners register at module load, so every module that binds keys or decorates must be required before the first transcript exists.
bugabinga.pivi requires them all up front; a spec that requires only one of them silently gets a buffer with fewer mappings.
A nameless buffer reads as the working directory.fnamemodify('', ':~:.') returns the cwd, so an unnamed scratch buffer makes a statusline claim the user is editing a file in it, which is where system32 came from under a launcher that starts in C:\Windows\system32.
Name every buffer pivi creates, floats included.
Never shadow a key whose meaning you are changing. pivi publishes <Plug> intents; a configuration binds them.
tests/bugabinga/pivi/keys_spec.lua asserts pivi claims no bare key in any of its buffers, with / in a prompt as the single declared exception.
Accepting a completion and accepting a prompt are the same intent, so <Plug>(pivi-accept) yields to an open popup instead of replacing it.
std.auto clears its group on every call. Registering two autocmds with the same group name in two calls silently deletes the first; pass one list instead.
A session may be a stand-in. The drive harness passes a stub session, so code reached from a command must not assume session.buffer is a live buffer.
This is exactly the class of regression the drive harness exists to catch; it caught it.
Pi runs the commands it owns and says the rest. A /name sent as a prompt is dispatched when Pi owns the command and becomes a user message when it does not, so a name must be checked against get_commands before it is sent. Terminal-only commands such as /model are absent from that list; offer the capability natively instead of forwarding the name.
A picker filters the text it is shown. Formatting an item as name description makes every description compete with the name; show the name alone.
A written prompt is not always a prompt.bugabinga.pivi.compose decides: a leading bang is a shell command through pi's bash, which accepts excludeFromContext even though rpc.md does not document it, and @name is expanded client-side because pi has no server-side @ over this channel.
Expansion reuses edit.read, so a named file is sent as its buffer content when it is open and unsaved; anything else would contradict every other pivi read.
The wire shape is pi's own: <file name="ABSOLUTE"> content </file>, images as images[].
A local in Lua is not visible before its declaration. Two functions have now been used above their definition and resolved to nil at runtime; if a helper is called from an earlier function, define it earlier.
Roles are named, not coloured.bugabinga.pivi.highlight defines PiviUser, PiviUserBar, PiviUserLabel, PiviAssistantLabel, PiviTool, PiviToolIcon, PiviToolOutcome, PiviError and PiviErrorIcon as defaults and re-applies them on ColorScheme; nugu defines them for real.
The intent any theme must keep: only a user message carries a background, because it is the one thing a reader scrolls back to find. An answer takes no colour at all. Tool output recedes furthest, and its outcome stays scannable.
Never reach for Comment to mean dim: in nugu it is bold, italic and important, which is the opposite.
Contract tests guard assumptions, not features. They skip unless pi is on PATH and opt-in is set. When Pi changes behavior, they fail before users do.
Commands
just test
just test tests/bugabinga/pivi
just drive
just test-contract
End-to-end proof that the companion extension answers from the live editor, when a tool changes:
just drive starts its own headless server, connects with
vim.fn.sockconnect('tcp', address, { rpc = true }), and drives it with
vim.rpcrequest(channel, 'nvim_exec_lua' | 'nvim_input' | 'nvim_get_mode', ...).
Requests are synchronous, so scenarios need no sleeps.
Add scenarios to tests/drive/run.lua.
Presentation
pivi decorates only its own windows and publishes state for everyone else.
Surface
Rule
Transcript and prompt windows
window-local winbar carries the session name; the prompt also names its submit key
Progress
transcript.set_state shows the running tool in pivi's own winbar
Statusline
lua/bugabinga/pivi_status.lua is a consumer: it reads vim.g.pivi and never requires pivi, so it degrades when pivi is absent
Headings
write ## pi only when real text arrives, so a tool-only turn leaves no empty heading
Leading blank lines
models open with them; strip them before the first visible character
Rejections
name the command the user should type, not the internal state
Transcript keys
buffer-local only, plus a <Plug> mapping; Neovim already ships ]m and [m globally, so assert on mapping descriptions rather than on the absence of a key
Folds
one fold per entry through foldexpr; appended text does not recompute folds until a redraw, so apply runs zX first and then drives folds from recorded intent
Tool outcome
end-of-line virtual text on the entry's opening line, and repeated in the fold line so a collapsed entry stays useful
Reported change
written as the tool formatted it and highlighted with DiffAdd/DiffDelete per line; bugabinga.pivi.diff rebuilds the prior content from the change itself and states when the file has moved on
Streaming output
rewrite only the entry tail, skip per-line decoration while it runs, and never run fold.apply mid-stream
Output language
parsed as a standalone string and applied as highlight extmarks, never as a fence, so transcript text stays what the tool produced
Keys
pivi binds no bare key and publishes <Plug> intents only; lua/bugabinga/pivi_keys.lua chooses the keys. A key is reused only where the local behaviour is the same idea as the global one, so <CR> activates, K shows information, ]] and [[ move by section, and folding needs no key because entries are real folds
Guide
? reads the mappings that exist in the buffer rather than a list written beside them, so it cannot drift
Layout
bugabinga.pivi.layout decides beside or below from the editor size, follows the user's splitright and splitbelow, and never carves a new window out of a pivi window. Headless neovim is a fixed 80x24 grid, so the decision is unit-tested as a pure function and the windows are verified in a real terminal
Gotchas
Resolve the Pi executable to a real path before spawning. On Windows the launcher is a .CMD shim, a bare pi reaches the spawner as ENOENT, and a Neovim started from a desktop launcher has a smaller PATH than a shell. A failed spawn must be reported, never raised as a traceback in the editor.
End a Pi child by closing its input and only killing it after a grace period. On Windows the launcher is a shim, so an immediate kill orphans the runtime behind it and those orphans keep later test runs from exiting. Contract tests share one child per command line and shut it down explicitly.
Plenary discovers spec files by spawning pwsh with a 5 second timeout. Under load that fails with E5108 and no tests run at all. Run the file directly when that happens; an empty summary is a harness failure, not a pass.
Never pipe a Plenary run into tail, grep, or head. Plenary spawns child Neovim processes that inherit stdout, so the pipe stays open and the shell appears to hang long after the tests finished. Redirect to a file and read the file.
A vim.wait predicate that already holds when the wait begins asserts nothing. run == 'idle' is true before the first frame is ever dispatched; wait for an observed event instead, or use the fake's flush barrier.
Real extension traffic dominates a recorded run: a recorded stream is mostly setStatus and setWidget, and the extensions clear their own entries on shutdown, so end-of-run status maps are empty by design.
Thinking levels are provider-specific. --thinking off is rejected by some endpoints; the run then fails with stopReason: "error" and an errorMessage on message_end, not with a transport failure.
nvim --server ... --remote-expr / --remote-send is unusable on win32 here: it spawns a TUI and swallows stdout. Use socket RPC.
A headless server has no UI, so screenstring() and friends are meaningless. Grid truth requires a real UI client or a WezTerm capture.
Overriding a built-in Pi tool requires matching its exact result and details shape; prefer injecting operations over reimplementing the tool, and assert the shape in a contract test.
A plain prompt while Pi is streaming is rejected; every submission path must choose steering or follow-up explicitly and a test must cover the rejection.
:Pi help must spawn its own session-less run; assert the working session's history, queue, and status are untouched.
Module conventions
Follow neovim/AGENTS.md: no local M = {}, snake_case files, *_spec.lua mirroring source paths.
Test-visible entry points use the _G.BugabingaPivi global; library submodules do not.
# pivi — testing rules
pivi drives an external Pi process over JSONL RPC and mutates live buffers.
Both properties break the usual "require the module, assert side effects" pattern.
Follow these rules; they are load-bearing, not style.
Spec: `.system/specs/NVIM-SPEC-GMWDCMJZ-pivi-native-neovim-integration/index.md`.
Research (Pi 0.85.1 / nvim 0.12 API facts): `.system/research/NVIM-RESEARCH-KZYYEFY_-pi-and-neovim-capabilities-for-pivi/index.md`.
## Test layers
| Layer | Scope | Where | Runs |
| --- | --- | --- | --- |
| L1 pure | framing, delta assembly, status reducer, partial-arg parser, snapshots | `tests/bugabinga/pivi/*_spec.lua` | every `just test` |
| L2 fake transport | `pivi.rpc` fed scripted frames | `tests/bugabinga/pivi/rpc_spec.lua` | every `just test` |
| L3 editor effects | real buffers, extmarks, undo, qf/loclist, windows | `tests/bugabinga/pivi/editor/*_spec.lua` | every `just test` |
| L4 driven nvim | interactive/async behavior in a live nvim | `tests/drive/*.lua` | on demand |
| L5 Pi contract | real `pi --mode rpc` assumptions | `tests/contract/*_spec.lua` | opt-in only |
| L6 rendering | actual TUI pixels/layout | WezTerm pane capture | rare, manual |
Most pivi code belongs in L1. If a bug can only be caught in L4+, the logic is in the wrong place.
## Rules
1. **Keep the core pure.** Parsing, assembly, and state transitions are `fn(state, frame) -> state, effects`. No `vim.api` inside them.
2. **Inject the spawner.** `pivi.rpc` takes a `spawn` function; tests pass a fake. Never `rawset(vim, 'system', ...)` for pivi tests — pivi holds a long-lived process, and global monkeypatching leaks across specs.
3. **Frame tests must include hostile input:** records split at arbitrary byte boundaries, trailing `\r`, `U+2028`/`U+2029` inside JSON strings, interleaved responses and events, unknown event types, stderr noise, child exit mid-stream.
4. **Chunk-split fuzz is mandatory** for the decoder: replay one golden transcript under many random split points and assert an identical frame sequence.
5. **Never sleep.** Use `vim.wait(ms, predicate)` or an RPC roundtrip as a flush barrier. A bare `vim.wait(200)` in a committed test is a bug.
6. **Assert data, not screens,** except in L6.
7. **Prove scheduling discipline.** Editor-facing callbacks assert `not vim.in_fast_event()`; RPC output callbacks run in fast contexts and must defer.
8. **Ghost text must be provably inert:** feed incomplete tool arguments, assert `b:changedtick` is unchanged.
9. **Undo coherence is a test, not a hope:** apply an edit, press `u` once, assert full revert; force the undo-join refusal path and assert the edit survives.
10. **Namespace isolation is a test:** agent diagnostics/extmarks in pivi's own namespace, LSP namespaces untouched.
11. **Clean up in `after_each`:** delete created buffers, clear namespaces, stop fake/real children, `package.loaded['bugabinga.pivi.*'] = nil`.
12. **Consumers degrade:** at least one spec asserts a statusline/consumer works with pivi never loaded, and that status evaluation performs no RPC.
13. **Fixtures come from reality.** Golden JSONL under `tests/fixtures/pivi/` is recorded from a real `pi --mode rpc` run with the user's own Pi configuration loaded, never hand-written from memory.
Read them through `tests/helpers/pivi_fixture.lua`.
| Fixture | Contents |
| --- | --- |
| `rpc-no-llm.jsonl` | responses only, no model call |
| `rpc-provider-error.jsonl` | a real failed run, provider error on `message_end` |
| `rpc-text-stream.jsonl` | a streamed answer, thinking and text deltas, extension traffic |
| `rpc-tool-stream.jsonl` | a streamed tool call, partial argument deltas, tool execution, extension traffic |
Record a new one cheaply against a local model, never a metered one:
```
{ printf '%s\n' '{"id":"p1","type":"prompt","message":"..."}'; sleep 40; } | pi --mode rpc --no-session --thinking low --model hetzner/Qwen3.8-27B > fixture.jsonl
```
14. **Bring your own Pi.** pivi adds `-e <companion extension>` and nothing else to the user's Pi.
Never pass `--no-extensions`, `--no-skills`, `--no-prompt-templates`, `--no-context-files`, `--no-builtin-tools`, or `--system-prompt`, in product code, in contract tests, or while recording fixtures.
`:Pi help` isolates session state only: `--no-session`, `--no-tools`, `--append-system-prompt`.
Specs assert this on both command builders; keep them.
A run started without the user's configuration proves nothing, and silently changes which providers exist.
15. **Help context stays small and live.** `:Pi help` attaches only the visible region of the current window plus layout, selection, recent messages, and the last error; never whole buffers.
Depth is fetched on demand: `nvim_help` for Neovim and plugin documentation, `pi-knowledge` for Pi, the `pivi` skill for pivi, `nvim_state` and `nvim_lua` for live values.
Never answer a Neovim documentation question from model memory in this codebase: `nvim_help` resolves against the running version's runtime path, including plugin docs.
Help is read-only by construction; keep mutating tools out of its allowlist.
16. **Edits stay proportional.** Never rewrite a whole buffer for a local change: `edit.replace` rewrites only the spanned lines and `edit.set_content` diffs first.
A whole-buffer replacement marks every line as agent work, destroys marks and folds, and makes the trail useless.
Assert the trail covers only the changed lines.
17. **The trail belongs to the agent until the user edits.** `activity.touch` records the agent-owned revision; a later foreign change clears the trail.
Anything the agent's own operation triggers, including a write, must call `touch` again, or an unrelated plugin reacting to the write wipes the trail.
A revision recorded before the operation finishes is not enough; re-record after the event loop settles.
18. **The transcript buffer holds message text, nothing else.** Framing, labels, quoting and tool markers are extmark decoration in `bugabinga.pivi.render`.
A spec must assert both that the framing is absent from the text and that it is present as decoration; otherwise the next change silently reintroduces prefixes.
19. **Entry bounds are extmark-backed.** `bugabinga.pivi.entry` re-anchors on every write, and an entry ends at its last non-empty row because appending a complete line always leaves a trailing empty row behind.
Never address an entry by a remembered line number.
20. **Never force a parse per streamed delta.** Markdown rendering is `vim.treesitter.start` and stays redraw-driven; a forced parse per delta measured roughly five hundred times slower.
Keep the streaming budget spec in `tests/bugabinga/pivi/editor/render_spec.lua`.
21. **Never invent an outcome.** `bugabinga.pivi.tool` derives everything from what a tool reported in its result details, and a tool that reported nothing gets no outcome, no language, and no change.
Pi reports a change on every `edit`, progress on `bash`, and truncation only at a limit; `write` reports nothing at all.
Every interpretation needs a spec for the reporting tool and a spec for the silent one.
22. **Entry shape changes without the buffer changing.** Re-anchoring an entry moves its bounds while touching no character, so anything cached per buffer must key on `entry.version` as well as `b:changedtick`.
A fold cache keyed on the tick alone silently goes stale the moment an entry is rewritten.
23. **A hook only reaches buffers made after it.** `transcript.on_buffer`, `on_window` and `on_reset` listeners register at module load, so every module that binds keys or decorates must be required before the first transcript exists.
`bugabinga.pivi` requires them all up front; a spec that requires only one of them silently gets a buffer with fewer mappings.
24. **A nameless buffer reads as the working directory.** `fnamemodify('', ':~:.')` returns the cwd, so an unnamed scratch buffer makes a statusline claim the user is editing a file in it, which is where `system32` came from under a launcher that starts in `C:\Windows\system32`.
Name every buffer pivi creates, floats included.
25. **Never shadow a key whose meaning you are changing.** pivi publishes `<Plug>` intents; a configuration binds them.
`tests/bugabinga/pivi/keys_spec.lua` asserts pivi claims no bare key in any of its buffers, with `/` in a prompt as the single declared exception.
Accepting a completion and accepting a prompt are the same intent, so `<Plug>(pivi-accept)` yields to an open popup instead of replacing it.
26. **`std.auto` clears its group on every call.** Registering two autocmds with the same group name in two calls silently deletes the first; pass one list instead.
27. **A session may be a stand-in.** The drive harness passes a stub session, so code reached from a command must not assume `session.buffer` is a live buffer.
This is exactly the class of regression the drive harness exists to catch; it caught it.
28. **Pi runs the commands it owns and says the rest.** A `/name` sent as a prompt is dispatched when Pi owns the command and becomes a user message when it does not, so a name must be checked against `get_commands` before it is sent. Terminal-only commands such as `/model` are absent from that list; offer the capability natively instead of forwarding the name.
29. **A picker filters the text it is shown.** Formatting an item as `name description` makes every description compete with the name; show the name alone.
30. **A written prompt is not always a prompt.** `bugabinga.pivi.compose` decides: a leading bang is a shell command through pi's `bash`, which accepts `excludeFromContext` even though `rpc.md` does not document it, and `@name` is expanded client-side because pi has no server-side `@` over this channel.
Expansion reuses `edit.read`, so a named file is sent as its buffer content when it is open and unsaved; anything else would contradict every other pivi read.
The wire shape is pi's own: `<file name="ABSOLUTE">
content
</file>`, images as `images[]`.
31. **A local in Lua is not visible before its declaration.** Two functions have now been used above their definition and resolved to `nil` at runtime; if a helper is called from an earlier function, define it earlier.
32. **Roles are named, not coloured.** `bugabinga.pivi.highlight` defines `PiviUser`, `PiviUserBar`, `PiviUserLabel`, `PiviAssistantLabel`, `PiviTool`, `PiviToolIcon`, `PiviToolOutcome`, `PiviError` and `PiviErrorIcon` as defaults and re-applies them on `ColorScheme`; nugu defines them for real.
The intent any theme must keep: only a user message carries a background, because it is the one thing a reader scrolls back to find. An answer takes no colour at all. Tool output recedes furthest, and its outcome stays scannable.
Never reach for `Comment` to mean dim: in nugu it is bold, italic and important, which is the opposite.
33. **Contract tests guard assumptions,** not features. They skip unless `pi` is on `PATH` and opt-in is set. When Pi changes behavior, they fail before users do.
## Commands
```
just test
just test tests/bugabinga/pivi
just drive
just test-contract
```
End-to-end proof that the companion extension answers from the live editor, when a tool changes:
```
nvim --headless --noplugin -u tests/minimal_init.lua --listen 127.0.0.1:6911
PIVI_NVIM_ADDRESS=127.0.0.1:6911 PIVI_NVIM_CLIENT=$PWD/lua/bugabinga/pivi/extension/client.lua pi --mode rpc --no-session --thinking low --model hetzner/Qwen3.8-27B --tools nvim_help --skill lua/bugabinga/pivi/skills/pivi/SKILL.md -e lua/bugabinga/pivi/extension/index.ts
```
`just drive` starts its own headless server, connects with
`vim.fn.sockconnect('tcp', address, { rpc = true })`, and drives it with
`vim.rpcrequest(channel, 'nvim_exec_lua' | 'nvim_input' | 'nvim_get_mode', ...)`.
Requests are synchronous, so scenarios need no sleeps.
Add scenarios to `tests/drive/run.lua`.
## Presentation
pivi decorates only its own windows and publishes state for everyone else.
| Surface | Rule |
| --- | --- |
| Transcript and prompt windows | window-local `winbar` carries the session name; the prompt also names its submit key |
| Progress | `transcript.set_state` shows the running tool in pivi's own winbar |
| Statusline | `lua/bugabinga/pivi_status.lua` is a consumer: it reads `vim.g.pivi` and never requires pivi, so it degrades when pivi is absent |
| Headings | write `## pi` only when real text arrives, so a tool-only turn leaves no empty heading |
| Leading blank lines | models open with them; strip them before the first visible character |
| Rejections | name the command the user should type, not the internal state |
| Transcript keys | buffer-local only, plus a `<Plug>` mapping; Neovim already ships `]m` and `[m` globally, so assert on mapping descriptions rather than on the absence of a key |
| Folds | one fold per entry through `foldexpr`; appended text does not recompute folds until a redraw, so `apply` runs `zX` first and then drives folds from recorded intent |
| Tool outcome | end-of-line virtual text on the entry's opening line, and repeated in the fold line so a collapsed entry stays useful |
| Reported change | written as the tool formatted it and highlighted with `DiffAdd`/`DiffDelete` per line; `bugabinga.pivi.diff` rebuilds the prior content from the change itself and states when the file has moved on |
| Streaming output | rewrite only the entry tail, skip per-line decoration while it runs, and never run `fold.apply` mid-stream |
| Output language | parsed as a standalone string and applied as highlight extmarks, never as a fence, so transcript text stays what the tool produced |
| Keys | pivi binds no bare key and publishes `<Plug>` intents only; `lua/bugabinga/pivi_keys.lua` chooses the keys. A key is reused only where the local behaviour is the same idea as the global one, so `<CR>` activates, `K` shows information, `]]` and `[[` move by section, and folding needs no key because entries are real folds |
| Guide | `?` reads the mappings that exist in the buffer rather than a list written beside them, so it cannot drift |
| Layout | `bugabinga.pivi.layout` decides beside or below from the editor size, follows the user's `splitright` and `splitbelow`, and never carves a new window out of a pivi window. Headless neovim is a fixed 80x24 grid, so the decision is unit-tested as a pure function and the windows are verified in a real terminal |
## Gotchas
- Resolve the Pi executable to a real path before spawning. On Windows the launcher is a `.CMD` shim, a bare `pi` reaches the spawner as `ENOENT`, and a Neovim started from a desktop launcher has a smaller `PATH` than a shell. A failed spawn must be reported, never raised as a traceback in the editor.
- End a Pi child by closing its input and only killing it after a grace period. On Windows the launcher is a shim, so an immediate kill orphans the runtime behind it and those orphans keep later test runs from exiting. Contract tests share one child per command line and shut it down explicitly.
- Plenary discovers spec files by spawning `pwsh` with a 5 second timeout. Under load that fails with `E5108` and no tests run at all. Run the file directly when that happens; an empty summary is a harness failure, not a pass.
- Never pipe a Plenary run into `tail`, `grep`, or `head`. Plenary spawns child Neovim processes that inherit stdout, so the pipe stays open and the shell appears to hang long after the tests finished. Redirect to a file and read the file.
- A `vim.wait` predicate that already holds when the wait begins asserts nothing. `run == 'idle'` is true before the first frame is ever dispatched; wait for an observed event instead, or use the fake's `flush` barrier.
- Real extension traffic dominates a recorded run: a recorded stream is mostly `setStatus` and `setWidget`, and the extensions clear their own entries on shutdown, so end-of-run status maps are empty by design.
- Thinking levels are provider-specific. `--thinking off` is rejected by some endpoints; the run then fails with `stopReason: "error"` and an `errorMessage` on `message_end`, not with a transport failure.
- `nvim --server ... --remote-expr` / `--remote-send` is unusable on win32 here: it spawns a TUI and swallows stdout. Use socket RPC.
- A headless server has no UI, so `screenstring()` and friends are meaningless. Grid truth requires a real UI client or a WezTerm capture.
- Overriding a built-in Pi tool requires matching its exact result and `details` shape; prefer injecting operations over reimplementing the tool, and assert the shape in a contract test.
- A plain `prompt` while Pi is streaming is rejected; every submission path must choose steering or follow-up explicitly and a test must cover the rejection.
- `:Pi help` must spawn its own session-less run; assert the working session's history, queue, and status are untouched.
## Module conventions
Follow `neovim/AGENTS.md`: no `local M = {}`, snake_case files, `*_spec.lua` mirroring source paths.
Test-visible entry points use the `_G.BugabingaPivi` global; library submodules do not.