Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ The expanded lazy.nvim configuration below shows the plugin's actual defaults. I
binary = "pi",
extensions = true,
skills = false,
skill_paths = {},
thinking = "off",
reload = true,
auto_close = true,
Expand Down Expand Up @@ -174,12 +175,78 @@ The expanded lazy.nvim configuration below shows the plugin's actual defaults. I

Without lazy.nvim, pass the contents of `opts` above to `require("ai").setup()`.

Pi runs in RPC mode with session persistence disabled. Extensions are enabled by default, while skills are disabled unless explicitly enabled.
Pi runs in RPC mode with session persistence disabled. Extensions are enabled by default.

After each completed AI turn, `reload = true` reloads every loaded file buffer whose file changed on disk during that turn. This keeps Neovim synchronized with agent edits, including replacing unsaved buffer contents when the agent changed the same file. Set `reload = false` to disable this behavior.

Set `chat.show_hints = false` to hide the contextual hint bar. The `chat.hints.input` and `chat.hints.display` lists are rendered exactly in their configured order. Set `chat.keys = false` to disable all chat-specific keymaps, or set `chat.keys.input` or `chat.keys.display` to `false` to disable one group.

### Skills

No skills are bundled or enabled by default.

- `skills = true` enables Pi's skill discovery, including `~/.agents/skills/`.
- `skill_paths = { ... }` loads local `SKILL.md` files or directories, even with `skills = false`.

Only a leading `~/` is expanded. Other path text is passed to Pi unchanged, and relative paths use the agent's working directory.

Pi loads skill instructions when needed. To invoke a skill explicitly, send `/skill:name` followed by your request.

### System prompt

Append text, file contents, or both:

```lua
require("ai").setup({
append_system_prompt = "Keep responses concise.",
append_system_prompt_filepath = "~/.config/nvim/ai/writing.md",
})
```

Both options are optional and accept a string or list of strings. Text comes first, then file contents, in list order with blank lines between entries.

To append multiple prompts:

```lua
require("ai").setup({
append_system_prompt = {
"Keep responses concise.",
"Explain your changes.",
},
append_system_prompt_filepath = {
"~/.config/nvim/ai/writing.md",
"~/.config/nvim/ai/project.md",
},
})
```

Files are read when opening a new session, including with Ctrl-N. Missing or unreadable files stop startup with an error notification. Only a leading `~/` is expanded; relative paths use the agent's working directory.

The plugin passes a nonempty combined prompt through an owner-only temporary file, so its size does not count against the operating system's command-line limit. `append_system_prompt` text stays literal even when it matches an existing filename.

The separate `system_prompt` option replaces Pi's default prompt. When its value names an existing path, Pi keeps its file-input behavior and ai.nvim leaves that file in place. Other nonempty values, including large literal prompts, use an owner-only temporary file. The plugin removes its temporary files when the backend exits, the session is stopped, or startup fails. Model context limits still apply.

### Recommended upstream skill

[Unslop](https://github.com/cursor/plugins/blob/main/pstack/skills/unslop/SKILL.md) by [Lauren Tan](https://github.com/poteto) removes common AI writing patterns. It is [MIT licensed](https://github.com/cursor/plugins/blob/main/pstack/LICENSE).

With Unslop installed in `~/.agents/skills/unslop/`:

```lua
require("ai").setup({
skill_paths = {
"~/.agents/skills/unslop/SKILL.md",
},
append_system_prompt_filepath = "~/.config/nvim/ai/unslop.md",
})
```

Unslop disables automatic model invocation. Use `/skill:unslop`, or create `~/.config/nvim/ai/unslop.md` with:

```markdown
Before writing or editing prose, read `~/.agents/skills/unslop/SKILL.md` and follow its Unslop instructions.
```

## Tests

```sh
Expand Down
1 change: 1 addition & 0 deletions lua/ai/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ M.defaults = {
binary = "pi",
extensions = true,
skills = false,
skill_paths = {},
thinking = "off",
reload = true,
auto_close = true,
Expand Down
52 changes: 41 additions & 11 deletions lua/ai/pi/command.lua
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
local M = {}
local Prompt = require("ai.prompt")

---@class ai.PiCommandOpts
---@field cmd? string[]
---@field binary? string
---@field extensions? boolean
---@field skills? boolean
---@field skills? boolean Enable Pi's skill discovery.
---@field skill_paths? string[] Local skill files or directories, loaded regardless of discovery.
---@field provider? string
---@field model? string
---@field thinking? string
---@field system_prompt? string
---@field append_system_prompt? string|string[]
---@field append_system_prompt? string|string[] Text to append.
---@field append_system_prompt_filepath? string|string[] Readable local files to append after text.

---@class ai.PiOpts: ai.PiCommandOpts
---@field agent_spawn_dir string
Expand All @@ -25,10 +28,18 @@ local function add_option(command, flag, value)
end
end

local function expand_home(path)
if path:sub(1, 2) == "~/" then
return vim.fn.expand("~") .. path:sub(2)
end
return path
end

---Builds the command used to launch the Pi RPC process.
---@param opts ai.PiCommandOpts
---@param prepare_prompt? fun(text: string, flag: string): string
---@return string[]
function M.build(opts)
function M.build(opts, prepare_prompt)
assert(type(opts) == "table", "Pi options are required")

if opts.cmd then
Expand All @@ -52,20 +63,39 @@ function M.build(opts)
if not opts.skills then
command[#command + 1] = "--no-skills"
end
if opts.skill_paths ~= nil then
assert(
type(opts.skill_paths) == "table" and vim.islist(opts.skill_paths),
"skill_paths must be a list of paths"
)
for _, path in ipairs(opts.skill_paths) do
assert(
type(path) == "string",
"skill_paths entries must be strings"
)
assert(
not path:match("^%a[%w+.-]*://"),
"skill_paths entries must be local paths: " .. path
)
add_option(command, "--skill", expand_home(path))
end
end

add_option(command, "--provider", opts.provider)
add_option(command, "--model", opts.model)
add_option(command, "--thinking", opts.thinking)
add_option(command, "--system-prompt", opts.system_prompt)

local prompts = opts.append_system_prompt
if type(prompts) == "string" then
prompts = { prompts }
end
for _, prompt in ipairs(prompts or {}) do
add_option(command, "--append-system-prompt", prompt)
local function add_prompt(flag, text)
if text ~= nil and text ~= "" then
assert(type(text) == "string", flag .. " must be a string")
if prepare_prompt then
text = prepare_prompt(text, flag)
end
add_option(command, flag, text)
end
end

add_prompt("--system-prompt", opts.system_prompt)
add_prompt("--append-system-prompt", Prompt.build(opts))
return command
end

Expand Down
82 changes: 55 additions & 27 deletions lua/ai/pi/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,30 @@
---@field request_id integer
---@field cancelled boolean
---@field stdin_closed boolean
---@field prompt_files string[]
---@field result? vim.SystemCompleted
local Pi = {}
Pi.__index = Pi

local Command = require("ai.pi.command")
local Events = require("ai.events")
local Prompt = require("ai.prompt")

local AIAction = Events.AIAction
local EventType = Events.Type

---Releases prompt files before user callbacks can interrupt teardown.
local function cleanup_prompt_files(pi)
local paths = pi.prompt_files
pi.prompt_files = {}
for _, path in ipairs(paths) do
local removed, err, code = vim.uv.fs_unlink(path)
if not removed and code ~= "ENOENT" then
pcall(vim.notify, "Could not delete temporary system prompt file: " .. tostring(err), vim.log.levels.WARN)
end
end
end

-- Decodes one JSON event line without raising parse errors.
local function decode_event(line)
local ok, decoded = pcall(vim.json.decode, line)
Expand Down Expand Up @@ -287,6 +301,7 @@ function Pi:cancel()
if not ok or not closing then
pcall(self.process.kill, self.process, 15)
end
cleanup_prompt_files(self)
end

---Builds the command used to launch the Pi RPC process.
Expand Down Expand Up @@ -314,38 +329,51 @@ function Pi.start(opts, dispatch)
cancelled = false,
stdin_closed = false,
result = nil,
prompt_files = {},
}, Pi)

local ok, process = pcall(vim.system, Command.build(opts), {
cwd = opts.agent_spawn_dir,
text = true,
stdin = true,
-- Feeds scheduled stdout chunks into the Pi line buffer.
stdout = vim.schedule_wrap(function(err, data)
if err then
pi.dispatch({
type = EventType.ERROR,
message = tostring(err),
source = "stdout",
})
return
end
if not pi.cancelled then
pi:feed_stdout(data)
local ok, process = pcall(function()
local command = Command.build(opts, function(text, flag)
if flag == "--system-prompt" and Prompt.is_path(text, opts.agent_spawn_dir) then
return text
end
end),
}, vim.schedule_wrap(function(result)
-- Flushes buffered output and reports process completion.
pi:flush_stdout()
pi.pending_messages = {}
pi.result = result
pi.dispatch({
type = EventType.EXIT,
result = pi.result,
})
end))
local path = Prompt.write_temp(text)
pi.prompt_files[#pi.prompt_files + 1] = path
return path
end)
return vim.system(command, {
cwd = opts.agent_spawn_dir,
text = true,
stdin = true,
-- Feeds scheduled stdout chunks into the Pi line buffer.
stdout = vim.schedule_wrap(function(err, data)
if err then
pi.dispatch({
type = EventType.ERROR,
message = tostring(err),
source = "stdout",
})
return
end
if not pi.cancelled then
pi:feed_stdout(data)
end
end),
}, vim.schedule_wrap(function(result)
cleanup_prompt_files(pi)
-- Flushes buffered output and reports process completion.
pi:flush_stdout()
pi.pending_messages = {}
pi.result = result
pi.dispatch({
type = EventType.EXIT,
result = pi.result,
})
end))
end)

if not ok then
cleanup_prompt_files(pi)
return nil, process
end

Expand Down
Loading
Loading