diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index 98e5726..0000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: Tests
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - uses: oven-sh/setup-bun@v2
-
- - name: Install dependencies
- run: bun install
- working-directory: cli
-
- - name: Run CLI tests
- run: bun test
- working-directory: cli
-
- - name: Install Neovim
- run: sudo apt-get update && sudo apt-get install -y neovim
-
- - name: Run Neovim Lua tests
- run: nvim --headless -u NONE --cmd "set rtp^=$PWD" +'luafile tests/context_picker_spec.lua' +qa
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..32d5804
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,48 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Neovim ${{ matrix.neovim }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ neovim: [v0.10.4, stable]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ - name: Install Neovim
+ uses: rhysd/action-setup-vim@v1
+ with:
+ neovim: true
+ version: ${{ matrix.neovim }}
+
+ - name: Checkout mini.test
+ uses: actions/checkout@v7
+ with:
+ repository: nvim-mini/mini.test
+ ref: 35c67cb7f2dc697ed0c7c06400fcf29a8a521d7d
+ path: .deps/mini.test
+
+ - name: Run tests
+ run: nvim --headless --noplugin -u tests/minimal_init.lua -c "lua MiniTest.run()"
+
+ - name: Compile runtime Lua
+ run: >-
+ nvim --headless -u NONE
+ -c "lua for _, f in ipairs(vim.fn.glob('lua/**/*.lua', false, true)) do assert(loadfile(f)) end; assert(loadfile('plugin/patch.lua'))"
+ -c qa
diff --git a/.gitignore b/.gitignore
index 4a1e809..dee976e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,3 @@
-cli/tau
-cli/.*.bun-build
-cli/node_modules/
.DS_Store
-*.log
-
+.deps/
node_modules/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3ca4a56..dda95e2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,46 +1,17 @@
-# Contributing to tau.nvim
-
-Thanks for your interest in contributing!
-
-## Getting Started
-
-1. Fork the repo and clone your fork
-2. Ensure you have [Neovim 0.10+](https://neovim.io) and [Bun](https://bun.sh) installed
-3. Build the CLI: `cd cli && bun build --compile src/index.ts --outfile tau`
-
-## Making Changes
-
-1. Create a branch from `main`
-2. Make your changes
-3. Test manually in Neovim to verify behavior
-4. Open a pull request against `main`
-
-## Commit Conventions
-
-This project follows [Conventional Commits](https://www.conventionalcommits.org/). Prefix your commit messages with a type:
-
-- `feat:` — new feature
-- `fix:` — bug fix
-- `docs:` — documentation changes
-- `chore:` — maintenance tasks (CI, dependencies, etc.)
-- `refactor:` — code changes that neither fix a bug nor add a feature
-
-Example: `feat: add support for streaming responses`
+# Contributing
## Pull Requests
-- Keep PRs focused — one feature or fix per PR
-- Write a clear description of what changed and why
-- Ensure existing functionality isn't broken
-
-## Reporting Issues
+1. Fork the repository.
+2. Create a focused branch for your change.
+3. Make your changes and add or update relevant tests.
+4. Open a pull request targeting `main` and describe what changed.
+5. Address any CI failures or review feedback.
-Open an issue with:
+## Tests
-- Neovim version (`nvim --version`)
-- Steps to reproduce
-- Expected vs actual behavior
+Tests run automatically on pull requests targeting `main`.
-## License
+## Issues and Questions
-By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
+For bug reports, issues, or questions, please [open an issue](https://github.com/timothyckl/patch.nvim/issues/new).
diff --git a/README.md b/README.md
index 50f2ba1..c8c2664 100644
--- a/README.md
+++ b/README.md
@@ -1,187 +1,83 @@
-# tau.nvim
+# patch.nvim
-A minimal Neovim plugin for applying LLM edits to a visual selection or range.
+A simple agentic inline code editor for Neovim.
-Tau sends your instruction, selected code, nearby context, and optional context files to an OpenAI-compatible `/chat/completions` API, then streams the proposed edit back into Neovim for review.
+Requires Neovim 0.10+, [nui.nvim](https://github.com/MunifTanjim/nui.nvim), and an authenticated [Pi](https://github.com/earendil-works/pi) installation.
-
+## How it works
-## Install
+1. Enter Visual Line (`Shift + v`) mode and highlight some text or empty space.
+2. Send an instruction, such as “Refactor this function” or “Add type hints.”
+3. Wait for the suggestion, then accept, reject, or retry it.
-Requirements: Neovim 0.10+, [Bun](https://bun.sh), and an OpenAI-compatible API endpoint.
+## Authentication
-Using [lazy.nvim](https://github.com/folke/lazy.nvim):
+Authenticate through the Pi coding agent before using patch.nvim. Start Pi, enter `/login` (or `/login `), and follow the prompts:
-```lua
-{
- "timothyckl/tau.nvim",
- lazy = false,
- build = "cd cli && bun run build",
- config = function()
- require("tau").setup({
- api_url = "https://api.openai.com/v1", -- Tau appends /chat/completions
- api_key = vim.env.OPENAI_API_KEY,
- model = "gpt-4o",
- })
- end,
- keys = {
- { "t", ":Tau", mode = "v", desc = "Tau: edit selection" },
- { "", ":TauContext", mode = "n", desc = "Tau: context files" },
- },
-}
+```sh
+pi
```
-Using `vim.pack`:
+Alternatively, authenticate an OAuth provider from the command line:
-`tau.nvim` requires [Bun](https://bun.sh) to build the CLI binary at `cli/tau`.
-The plugin is not usable until that build has completed, because `:Tau`,
-`:TauContext`, and `:TauCancel` all rely on the compiled CLI being present.
-
-Minimal install:
-
-```lua
-vim.pack.add({
- {
- src = "https://github.com/timothyckl/tau.nvim"
- },
-})
+```sh
+cd ~/.pi/agent
+npx @earendil-works/pi-ai login [provider]
```
-Complete example with automatic rebuilds on install and update:
+## Installation
-```lua
--- Define this before vim.pack.add() so fresh installs also build cli/tau.
-vim.api.nvim_create_autocmd("PackChanged", {
- callback = function(ev)
- local data = ev.data
- if data.spec.name ~= "tau.nvim" then
- return
- end
- if data.kind ~= "install" and data.kind ~= "update" then
- return
- end
-
- local result = vim.system({ "bun", "run", "build" }, {
- cwd = data.path .. "/cli",
- text = true,
- }):wait()
-
- if result.code ~= 0 then
- local output = vim.trim(
- (result.stderr and result.stderr ~= "") and result.stderr or (result.stdout or "")
- )
- if output == "" then
- output = ("bun run build exited with code %d"):format(result.code)
- end
- vim.notify(
- ("tau.nvim: failed to build cli/tau\n%s"):format(output),
- vim.log.levels.ERROR
- )
- end
- end,
-})
+[nvim-notify](https://github.com/rcarriga/nvim-notify) is optional. Omit it from the examples below and remove the `notify` option from the configuration to use `vim.notify` instead.
-vim.pack.add({
- { src = "https://github.com/timothyckl/tau.nvim" },
-})
-
-require("tau").setup({
- api_url = "https://api.openai.com/v1", -- Tau appends /chat/completions
- api_key = vim.env.OPENAI_API_KEY,
- model = "gpt-4o",
-})
-
-vim.keymap.set("v", "t", ":Tau", { desc = "Tau: edit selection" })
-vim.keymap.set("n", "", ":TauContext", { desc = "Tau: context files" })
-vim.keymap.set("n", "T", ":TauCancel", { desc = "Tau: cancel request" })
-```
+### lazy.nvim
-The `PackChanged` hook rebuilds `cli/tau` after every install and update, and
-`vim.system(...):wait()` keeps the build synchronous so the CLI is present
-before first use.
-
-If you hit `ENOENT: no such file or directory ... /cli/tau`, the CLI was not
-built yet. Rebuild it manually with:
-
-```sh
-cd /cli && bun run build
+```lua
+{
+ "tim/patch.nvim",
+ dependencies = {
+ "MunifTanjim/nui.nvim",
+ "rcarriga/nvim-notify",
+ },
+}
```
-For Ollama-compatible local servers:
+### packer.nvim
```lua
-require("tau").setup({
- api_url = "http://localhost:11434/v1",
- api_key = "ollama",
- model = "qwen2.5-coder:7b",
+use({
+ "tim/patch.nvim",
+ requires = {
+ "MunifTanjim/nui.nvim",
+ "rcarriga/nvim-notify",
+ },
})
```
-## Usage
-
-Select code in visual mode, then run:
+### vim-plug
```vim
-:Tau rewrite this to be simpler
+Plug 'MunifTanjim/nui.nvim'
+Plug 'rcarriga/nvim-notify'
+Plug 'tim/patch.nvim'
```
-Or run `:Tau` without an instruction to open the instruction picker. While reviewing a streamed proposal:
-
-| Key | Action |
-| --- | --- |
-| `` | Accept edit |
-| `` | Reject edit |
-| `r` | Regenerate edit |
-
-Tau automatically includes nearby lines from the active file. To add more files as context, press `` in the instruction picker or run `:TauContext`.
-
-In the context picker:
-
-| Key / Symbol | Meaning |
-| --- | --- |
-| `` | Toggle file under cursor |
-| `` / `q` | Close picker |
-| `◎` | Active file, always included |
-| `●` | Selected context file |
-
-Selected context files persist for the current Neovim session.
-
## Configuration
```lua
-require("tau").setup({
- api_url = "https://api.openai.com/v1",
- api_key = vim.env.OPENAI_API_KEY,
- model = "gpt-4o",
-
- -- optional
- context_lines = 30,
- timeout_ms = 60000,
- context_window = 128000,
- temperature = 0.2,
- max_tokens = 4096,
- top_p = 1,
- debug = false,
- keys = { context = "" },
+require("patch").setup({
+ notify = require("notify"),
+ -- system_prompt = "Custom system instructions...",
+ -- model = "openai/gpt-5.6-sol",
})
+
+vim.keymap.set("x", "p", "PatchCapture", { desc = "Capture selection and request a patch" })
+vim.keymap.set("n", "pa", "PatchAccept", { desc = "Accept the active patch proposal" })
+vim.keymap.set("n", "pr", "PatchReject", { desc = "Reject the active patch and restore the original" })
+vim.keymap.set("n", "pR", "PatchRetry", { desc = "Generate a new active patch proposal" })
+vim.keymap.set("n", "pc", "PatchCancel", { desc = "Cancel the active patch generation request" })
+vim.keymap.set("n", "pm", "PatchMenu", { desc = "Select the model used for patch requests" })
```
-| Option | Default | Description |
-| --- | --- | --- |
-| `api_url` | required | OpenAI-compatible API base URL |
-| `api_key` | required | API key; prefer environment variables |
-| `model` | `gpt-4o` | Model name |
-| `context_lines` | `30` | Lines above and below the selection to include |
-| `timeout_ms` | `60000` | Request timeout in milliseconds |
-| `context_window` | unset | Model context window, used for token estimates |
-| `temperature` | unset | Sampling temperature, `0` to `2` |
-| `max_tokens` | unset | Maximum response tokens |
-| `top_p` | unset | Nucleus sampling value, `0` to `1` |
-| `debug` | `false` | Write diagnostics to `~/.local/state/tau/diag.log` |
-| `keys.context` | `` | Open context picker from the instruction picker |
-
-Commands: `:Tau [instruction]`, `:TauCancel`, `:TauContext`.
-
-## License
-
-MIT
+`system_prompt` is optional. When set, it completely replaces Patch's built-in system prompt.
+
+`model` is optional. When omitted, Patch uses Pi's primary model. `PatchMenu` marks the effective model and allows selecting an override for the current Neovim session.
diff --git a/cli/bun.lock b/cli/bun.lock
deleted file mode 100644
index e5b6d85..0000000
--- a/cli/bun.lock
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "lockfileVersion": 1,
- "configVersion": 1,
- "workspaces": {
- "": {
- "name": "tau",
- "devDependencies": {
- "bun-types": "latest",
- },
- },
- },
- "packages": {
- "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
-
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
-
- "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
- }
-}
diff --git a/cli/package.json b/cli/package.json
deleted file mode 100644
index 1146ef7..0000000
--- a/cli/package.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "name": "tau",
- "version": "0.1.0",
- "scripts": {
- "build": "bun build --compile src/index.ts --outfile tau",
- "test": "bun test"
- },
- "devDependencies": {
- "bun-types": "latest"
- }
-}
diff --git a/cli/src/context.ts b/cli/src/context.ts
deleted file mode 100644
index d93cfe5..0000000
--- a/cli/src/context.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { readFileSync } from "node:fs"
-
-export interface UserMessageOpts {
- selection: string
- instruction: string
- filename?: string
- contextAbove?: string
- contextBelow?: string
- contextFiles?: string[]
-}
-
-export function buildUserMessage(opts: UserMessageOpts): string {
- const parts: string[] = []
-
- if (opts.contextFiles && opts.contextFiles.length > 0) {
- const fileBlocks: string[] = []
- for (const filePath of opts.contextFiles) {
- try {
- const content = readFileSync(filePath, "utf-8")
- fileBlocks.push(`--- ${filePath} ---\n${content}`)
- } catch {
- fileBlocks.push(`--- ${filePath} --- (unreadable)`)
- }
- }
- parts.push("[Context files]\n" + fileBlocks.join("\n\n"))
- }
-
- if (opts.contextAbove) {
- const header = opts.filename ? `--- ${opts.filename} ---\n[Context above]` : "[Context above]"
- parts.push(`${header}\n${opts.contextAbove}`)
- } else if (opts.filename) {
- parts.push(`--- ${opts.filename} ---`)
- }
-
- parts.push(`[Selected code]\n${opts.selection}`)
-
- if (opts.contextBelow) {
- parts.push(`[Context below]\n${opts.contextBelow}`)
- }
-
- parts.push(`[Instruction]\n${opts.instruction}`)
-
- return parts.join("\n\n")
-}
diff --git a/cli/src/index.ts b/cli/src/index.ts
deleted file mode 100644
index a166239..0000000
--- a/cli/src/index.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { buildSystemPrompt } from "./prompt"
-import { buildUserMessage } from "./context"
-import { streamDirect } from "./llm"
-import { estimatePrompt } from "./tokens"
-import { log } from "./log"
-
-function usage(): never {
- process.stderr.write(
- "Usage: tau [--context-above ] [--context-below ] [--file ] [--filetype ] [--context-file ]...\n"
- )
- process.exit(1)
-}
-
-function parseArgs(argv: string[]): {
- instruction: string
- contextAbove?: string
- contextBelow?: string
- filename?: string
- filetype?: string
- contextFiles: string[]
-} {
- const args = argv.slice(2)
- if (args.length === 0) usage()
-
- let instruction = ""
- let contextAbove: string | undefined
- let contextBelow: string | undefined
- let filename: string | undefined
- let filetype: string | undefined
- const contextFiles: string[] = []
-
- for (let i = 0; i < args.length; i++) {
- const arg = args[i]
- if (arg === "--context-above") {
- contextAbove = args[++i]
- } else if (arg === "--context-below") {
- contextBelow = args[++i]
- } else if (arg === "--file") {
- filename = args[++i]
- } else if (arg === "--filetype") {
- filetype = args[++i]
- } else if (arg === "--context-file") {
- const val = args[++i]
- if (val === undefined) {
- process.stderr.write("tau: --context-file requires a path argument\n")
- process.exit(1)
- }
- contextFiles.push(val)
- } else if (arg.startsWith("--")) {
- process.stderr.write(`tau: unknown flag ${arg}\n`)
- ++i // skip the next token (assumed value)
- } else {
- instruction = arg
- }
- }
-
- if (!instruction) usage()
-
- return { instruction, contextAbove, contextBelow, filename, filetype, contextFiles }
-}
-
-async function readStdin(): Promise {
- const chunks: Uint8Array[] = []
- for await (const chunk of Bun.stdin.stream()) {
- chunks.push(chunk)
- }
- return Buffer.concat(chunks).toString("utf-8")
-}
-
-function parseEnvFloat(name: string): number | undefined {
- const raw = process.env[name]
- if (!raw) return undefined
- const val = parseFloat(raw)
- if (isNaN(val)) {
- process.stderr.write(`tau: invalid value for ${name}: ${raw}\n`)
- process.exit(1)
- }
- return val
-}
-
-function parseEnvInt(name: string): number | undefined {
- const raw = process.env[name]
- if (!raw) return undefined
- const val = parseInt(raw, 10)
- if (isNaN(val)) {
- process.stderr.write(`tau: invalid value for ${name}: ${raw}\n`)
- process.exit(1)
- }
- return val
-}
-
-async function main() {
- const opts = parseArgs(process.argv)
-
- const selection = await readStdin()
-
- const apiUrl = process.env.TAU_API_URL
- const apiKey = process.env.TAU_API_KEY
- const model = process.env.TAU_MODEL ?? "gpt-4o"
- const temperature = parseEnvFloat("TAU_TEMPERATURE")
- const maxTokens = parseEnvInt("TAU_MAX_TOKENS")
- const topP = parseEnvFloat("TAU_TOP_P")
-
- if (!apiUrl) {
- process.stderr.write("tau: TAU_API_URL is not set\n")
- process.exit(1)
- }
- if (!apiKey) {
- process.stderr.write("tau: TAU_API_KEY is not set\n")
- process.exit(1)
- }
-
- const hasContextFiles = opts.contextFiles.length > 0
- const systemPrompt = buildSystemPrompt({ filename: opts.filename, filetype: opts.filetype, selectionEmpty: !selection.trim(), hasContextFiles })
- const userMessage = buildUserMessage({
- selection,
- instruction: opts.instruction,
- filename: opts.filename,
- contextAbove: opts.contextAbove,
- contextBelow: opts.contextBelow,
- contextFiles: opts.contextFiles,
- })
-
- const rawWindow = process.env.TAU_CONTEXT_WINDOW
- const parsedWindow = rawWindow ? parseInt(rawWindow, 10) : NaN
- const contextWindow = parsedWindow > 0 ? parsedWindow : undefined
- const estimate = estimatePrompt(systemPrompt, userMessage, contextWindow)
- process.stderr.write(`TAU_META:${JSON.stringify(estimate)}\n`)
- log("token-estimate: " + JSON.stringify(estimate))
-
- log("stream-start")
- try {
- await streamDirect(
- [
- { role: "system", content: systemPrompt },
- { role: "user", content: userMessage },
- ],
- { apiUrl, apiKey, model, temperature, maxTokens, topP },
- (token) => process.stdout.write(token)
- )
- log("stream-done")
- } catch (err) {
- log("stream-error: " + String(err))
- process.stderr.write(`tau: ${err instanceof Error ? err.message : String(err)}\n`)
- process.exit(1)
- }
-}
-
-main().then(() => {
- log("main-resolved, calling exit")
- process.exit(0)
-})
diff --git a/cli/src/llm.ts b/cli/src/llm.ts
deleted file mode 100644
index 3db2d3b..0000000
--- a/cli/src/llm.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { log } from "./log"
-
-export interface Message {
- role: "system" | "user" | "assistant"
- content: string
-}
-
-export interface LLMConfig {
- apiUrl: string
- apiKey: string
- model: string
- temperature?: number
- maxTokens?: number
- topP?: number
-}
-
-const TIMEOUT_MS = Number(process.env.TAU_TIMEOUT_MS) || 60_000
-
-async function fetchSSE(
- messages: Message[],
- config: LLMConfig
-): Promise<{ response: Response; timer: ReturnType }> {
- const controller = new AbortController()
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
-
- let response: Response
- try {
- response = await fetch(`${config.apiUrl}/chat/completions`, {
- method: "POST",
- signal: controller.signal,
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${config.apiKey}`,
- },
- body: JSON.stringify({
- model: config.model,
- messages,
- stream: true,
- ...(config.temperature !== undefined && { temperature: config.temperature }),
- ...(config.maxTokens !== undefined && { max_tokens: config.maxTokens }),
- ...(config.topP !== undefined && { top_p: config.topP }),
- }),
- })
- } catch (err) {
- clearTimeout(timer)
- if (err instanceof DOMException && err.name === "AbortError") {
- throw new Error(`LLM request timed out after ${TIMEOUT_MS}ms`)
- }
- throw err
- }
-
- if (!response.ok) {
- clearTimeout(timer)
- const body = await response.text()
- const truncated = body.length > 200 ? body.slice(0, 200) + "..." : body
- throw new Error(`LLM request failed (${response.status}): ${truncated}`)
- }
-
- if (!response.body) {
- clearTimeout(timer)
- throw new Error("No response body")
- }
-
- return { response, timer }
-}
-
-function parseSSELines(
- lines: string[],
- onContent: (s: string) => void
-): boolean {
- for (const line of lines) {
- const trimmed = line.trim()
- if (!trimmed.startsWith("data:")) continue
-
- const data = trimmed.slice(5).trim()
- if (data === "[DONE]") return true
-
- try {
- const parsed = JSON.parse(data)
- const content = parsed?.choices?.[0]?.delta?.content
- if (typeof content === "string" && content.length > 0) {
- onContent(content)
- }
- } catch {
- log(`malformed SSE chunk: ${data}`)
- }
- }
- return false
-}
-
-/** Non-generator streaming — for CLI use. */
-export async function streamDirect(
- messages: Message[],
- config: LLMConfig,
- write: (s: string) => void
-): Promise {
- const { response, timer } = await fetchSSE(messages, config)
-
- const reader = response.body!.getReader()
- const decoder = new TextDecoder()
- let lineBuffer = ""
-
- try {
- while (true) {
- const { done, value } = await reader.read()
- if (done) {
- log("reader done=true")
- break
- }
-
- lineBuffer += decoder.decode(value, { stream: true })
- const lines = lineBuffer.split("\n")
- lineBuffer = lines.pop() ?? ""
-
- if (parseSSELines(lines, write)) {
- log("[DONE] detected, returning")
- return
- }
- }
-
- // Flush any remaining buffered lines
- if (lineBuffer.trim()) {
- parseSSELines(lineBuffer.split("\n"), write)
- }
- log("loop exited naturally")
- } finally {
- clearTimeout(timer)
- await reader.cancel()
- }
-}
-
-/** Generator streaming — for tests and programmatic use. */
-export async function* stream(
- messages: Message[],
- config: LLMConfig
-): AsyncGenerator {
- const { response, timer } = await fetchSSE(messages, config)
-
- const reader = response.body!.getReader()
- const decoder = new TextDecoder()
- let lineBuffer = ""
-
- try {
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
-
- lineBuffer += decoder.decode(value, { stream: true })
- const lines = lineBuffer.split("\n")
- lineBuffer = lines.pop() ?? ""
-
- const collected: string[] = []
- if (parseSSELines(lines, (s) => collected.push(s))) {
- for (const s of collected) yield s
- return
- }
- for (const s of collected) yield s
- }
-
- // Flush any remaining buffered lines
- if (lineBuffer.trim()) {
- const collected: string[] = []
- parseSSELines(lineBuffer.split("\n"), (s) => collected.push(s))
- for (const s of collected) yield s
- }
- } finally {
- clearTimeout(timer)
- await reader.cancel()
- }
-}
diff --git a/cli/src/log.ts b/cli/src/log.ts
deleted file mode 100644
index 19b0d89..0000000
--- a/cli/src/log.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { appendFileSync, mkdirSync } from "fs"
-import { join } from "path"
-import { homedir } from "os"
-
-const debug = process.env.TAU_DEBUG === "1"
-
-let logPath: string | undefined
-
-function getLogPath(): string {
- if (!logPath) {
- const stateDir =
- process.env.XDG_STATE_HOME || join(homedir(), ".local", "state")
- const tauDir = join(stateDir, "tau")
- mkdirSync(tauDir, { recursive: true, mode: 0o700 })
- logPath = join(tauDir, "diag.log")
- }
- return logPath
-}
-
-export function log(msg: string): void {
- if (!debug) return
- appendFileSync(getLogPath(), `${Date.now()} ${msg}\n`)
-}
diff --git a/cli/src/prompt.ts b/cli/src/prompt.ts
deleted file mode 100644
index 23bae6d..0000000
--- a/cli/src/prompt.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-export interface PromptOpts {
- filename?: string
- filetype?: string
- selectionEmpty?: boolean
- hasContextFiles?: boolean
-}
-
-export function buildSystemPrompt(opts: PromptOpts): string {
- const fileLine = opts.filename
- ? `File: ${opts.filename}${opts.filetype ? ` (${opts.filetype})` : ""}`
- : opts.filetype
- ? `Language: ${opts.filetype}`
- : ""
-
- const intro = opts.selectionEmpty
- ? "You are a code editing assistant. The selected region is empty — generate new code to insert at that position."
- : "You are a code editing assistant. You will be given a code selection and an instruction."
-
- const outputRule = opts.selectionEmpty
- ? "Return ONLY the new code to insert. Your output will be placed directly at the empty region in the file."
- : "Return ONLY the replacement code for the selected region. Your output will directly replace the selected code in the file."
-
- const firstBullet = opts.selectionEmpty
- ? "Output ONLY the code to insert — nothing before it, nothing after it"
- : "Output ONLY the replacement for [Selected code] — nothing before it, nothing after it"
-
- const contextFilesRule = opts.hasContextFiles
- ? "- Reference [Context files] for broader project context when relevant to the instruction"
- : ""
-
- return [
- intro,
- outputRule,
- "CRITICAL RULES:",
- `- ${firstBullet}`,
- "- Do NOT repeat code from [Context above] or [Context below]",
- contextFilesRule,
- "- No markdown fences (no ```)",
- "- No explanation or commentary",
- "- Match the exact indentation of the selected code — use the same leading whitespace on every line",
- fileLine,
- ]
- .filter(Boolean)
- .join("\n")
-}
diff --git a/cli/src/tokens.ts b/cli/src/tokens.ts
deleted file mode 100644
index 7a07133..0000000
--- a/cli/src/tokens.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-export function estimateTokens(text: string): number {
- if (!text) return 0
- return Math.ceil(text.length / 4)
-}
-
-export interface TokenEstimate {
- estimated_tokens: number
- context_window?: number
- fill_pct?: number
- warning?: string
-}
-
-const MESSAGE_OVERHEAD_TOKENS = 8
-
-export function estimatePrompt(
- systemPrompt: string,
- userMessage: string,
- contextWindow?: number,
-): TokenEstimate {
- const estimated_tokens =
- estimateTokens(systemPrompt) + estimateTokens(userMessage) + MESSAGE_OVERHEAD_TOKENS
-
- const estimate: TokenEstimate = { estimated_tokens }
-
- if (contextWindow) {
- estimate.context_window = contextWindow
- estimate.fill_pct = Math.round((estimated_tokens / contextWindow) * 1000) / 10
-
- if (estimate.fill_pct > 100) {
- estimate.warning = "estimated prompt exceeds context window"
- }
- }
-
- return estimate
-}
diff --git a/cli/tests/context.test.ts b/cli/tests/context.test.ts
deleted file mode 100644
index 17d602b..0000000
--- a/cli/tests/context.test.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import { describe, test, expect, beforeAll, afterAll } from "bun:test"
-import { buildUserMessage } from "../src/context"
-import { writeFileSync, unlinkSync, mkdtempSync } from "node:fs"
-import { join } from "node:path"
-import { tmpdir } from "node:os"
-
-describe("buildUserMessage", () => {
- test("includes all sections when context provided", () => {
- const result = buildUserMessage({
- selection: "def foo():\n pass",
- instruction: "add type hints",
- contextAbove: "class Foo:",
- contextBelow: " bar = 1",
- })
- expect(result).toContain("[Context above]")
- expect(result).toContain("class Foo:")
- expect(result).toContain("[Selected code]")
- expect(result).toContain("def foo():")
- expect(result).toContain("[Context below]")
- expect(result).toContain(" bar = 1")
- expect(result).toContain("[Instruction]")
- expect(result).toContain("add type hints")
- })
-
- test("omits context above when not provided", () => {
- const result = buildUserMessage({
- selection: "def foo(): pass",
- instruction: "make async",
- })
- expect(result).not.toContain("[Context above]")
- expect(result).not.toContain("[Context below]")
- expect(result).toContain("[Selected code]")
- expect(result).toContain("[Instruction]")
- })
-
- test("handles empty selection", () => {
- const result = buildUserMessage({
- selection: "",
- instruction: "add a constructor",
- contextAbove: "class Foo {",
- contextBelow: "}",
- })
- expect(result).toContain("[Selected code]")
- expect(result).toContain("[Instruction]")
- expect(result).toContain("add a constructor")
- })
-
- test("sections appear in correct order", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- contextAbove: "ABOVE",
- contextBelow: "BELOW",
- })
- const aboveIdx = result.indexOf("ABOVE")
- const selIdx = result.indexOf("SEL")
- const belowIdx = result.indexOf("BELOW")
- const instIdx = result.indexOf("INST")
- expect(aboveIdx).toBeLessThan(selIdx)
- expect(selIdx).toBeLessThan(belowIdx)
- expect(belowIdx).toBeLessThan(instIdx)
- })
-
- test("sections separated by blank lines", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- contextAbove: "ABOVE",
- contextBelow: "BELOW",
- })
- expect(result).toContain("ABOVE\n\n[Selected code]")
- expect(result).toContain("SEL\n\n[Context below]")
- expect(result).toContain("BELOW\n\n[Instruction]")
- })
-
- describe("active file label", () => {
- test("label appears directly above [Context above] when filename provided", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- filename: "/path/to/active.lua",
- contextAbove: "ABOVE",
- })
- expect(result).toContain("--- /path/to/active.lua ---\n[Context above]")
- })
-
- test("label appears directly above [Selected code] when filename provided but no contextAbove", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- filename: "/path/to/active.lua",
- })
- expect(result).toContain("--- /path/to/active.lua ---\n\n[Selected code]")
- })
-
- test("no label when filename omitted", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- contextAbove: "ABOVE",
- })
- expect(result).not.toMatch(/^--- .* ---$/m)
- })
- })
-
- describe("context files", () => {
- let tmpDir: string
- let tmpFile: string
-
- beforeAll(() => {
- tmpDir = mkdtempSync(join(tmpdir(), "tau-test-"))
- tmpFile = join(tmpDir, "helper.ts")
- writeFileSync(tmpFile, "export function helper() { return 42 }")
- })
-
- afterAll(() => {
- try { unlinkSync(tmpFile) } catch {}
- })
-
- test("includes context files section with valid file", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- contextFiles: [tmpFile],
- })
- expect(result).toContain("[Context files]")
- expect(result).toContain(`--- ${tmpFile} ---`)
- expect(result).toContain("export function helper() { return 42 }")
- })
-
- test("file header and content not separated by blank line", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- contextFiles: [tmpFile],
- })
- expect(result).toContain(`--- ${tmpFile} ---\nexport function helper()`)
- })
-
- test("omits context files section when empty", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- contextFiles: [],
- })
- expect(result).not.toContain("[Context files]")
- })
-
- test("omits context files section when undefined", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- })
- expect(result).not.toContain("[Context files]")
- })
-
- test("marks unreadable files", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- contextFiles: ["/nonexistent/path/file.ts"],
- })
- expect(result).toContain("[Context files]")
- expect(result).toContain("(unreadable)")
- })
-
- test("context files appear before context above", () => {
- const result = buildUserMessage({
- selection: "SEL",
- instruction: "INST",
- contextAbove: "ABOVE",
- contextBelow: "BELOW",
- contextFiles: [tmpFile],
- })
- const filesIdx = result.indexOf("[Context files]")
- const aboveIdx = result.indexOf("[Context above]")
- const selIdx = result.indexOf("[Selected code]")
- expect(filesIdx).toBeLessThan(aboveIdx)
- expect(aboveIdx).toBeLessThan(selIdx)
- })
- })
-})
diff --git a/cli/tests/llm.test.ts b/cli/tests/llm.test.ts
deleted file mode 100644
index 97f3734..0000000
--- a/cli/tests/llm.test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { describe, test, expect } from "bun:test"
-import { stream } from "../src/llm"
-
-function sseChunks(tokens: string[]): string {
- return (
- tokens
- .map((t) => `data: ${JSON.stringify({ choices: [{ delta: { content: t } }] })}`)
- .join("\n") +
- "\ndata: [DONE]\n"
- )
-}
-
-async function collectStream(
- tokens: string[],
- splitAt?: number // split the SSE payload at this byte offset to simulate chunk boundaries
-): Promise {
- const payload = sseChunks(tokens)
-
- const server = Bun.serve({
- port: 0,
- fetch() {
- if (splitAt !== undefined) {
- // Stream in two chunks split at splitAt to test boundary handling
- const encoder = new TextEncoder()
- const bytes = encoder.encode(payload)
- const part1 = bytes.slice(0, splitAt)
- const part2 = bytes.slice(splitAt)
- const body = new ReadableStream({
- start(controller) {
- controller.enqueue(part1)
- controller.enqueue(part2)
- controller.close()
- },
- })
- return new Response(body, {
- headers: { "Content-Type": "text/event-stream" },
- })
- }
- return new Response(payload, {
- headers: { "Content-Type": "text/event-stream" },
- })
- },
- })
-
- const url = `http://localhost:${server.port}`
- let result = ""
- try {
- for await (const token of stream(
- [{ role: "user", content: "test" }],
- { apiUrl: url, apiKey: "test", model: "test-model" }
- )) {
- result += token
- }
- } finally {
- server.stop()
- }
- return result
-}
-
-describe("stream", () => {
- test("collects tokens from SSE response", async () => {
- const result = await collectStream(["async ", "def ", "foo():"])
- expect(result).toBe("async def foo():")
- })
-
- test("handles chunk boundary mid-event", async () => {
- // Split the SSE payload mid-way through the first event line
- const result = await collectStream(["hello ", "world"], 20)
- expect(result).toBe("hello world")
- })
-
- test("handles single-token response", async () => {
- const result = await collectStream(["done"])
- expect(result).toBe("done")
- })
-
- test("throws on non-200 response", async () => {
- const server = Bun.serve({
- port: 0,
- fetch() {
- return new Response("internal error", { status: 500 })
- },
- })
-
- const url = `http://localhost:${server.port}`
- let threw = false
- try {
- for await (const _ of stream(
- [{ role: "user", content: "test" }],
- { apiUrl: url, apiKey: "test", model: "test-model" }
- )) {
- // should not reach here
- }
- } catch (err) {
- threw = true
- expect(String(err)).toContain("500")
- } finally {
- server.stop()
- }
- expect(threw).toBe(true)
- })
-})
diff --git a/cli/tests/prompt.test.ts b/cli/tests/prompt.test.ts
deleted file mode 100644
index 3d73cfe..0000000
--- a/cli/tests/prompt.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, test, expect } from "bun:test"
-import { buildSystemPrompt } from "../src/prompt"
-
-describe("buildSystemPrompt", () => {
- test("includes filename and filetype", () => {
- const result = buildSystemPrompt({ filename: "main.py", filetype: "python" })
- expect(result).toContain("File: main.py (python)")
- })
-
- test("includes filename without filetype", () => {
- const result = buildSystemPrompt({ filename: "main.py" })
- expect(result).toContain("File: main.py")
- expect(result).not.toContain("()")
- })
-
- test("includes filetype without filename", () => {
- const result = buildSystemPrompt({ filetype: "go" })
- expect(result).toContain("Language: go")
- })
-
- test("works with no opts", () => {
- const result = buildSystemPrompt({})
- expect(result).toContain("Return ONLY the replacement code")
- expect(result).not.toContain("File:")
- expect(result).not.toContain("Language:")
- })
-
- test("instructs no markdown fences", () => {
- const result = buildSystemPrompt({})
- expect(result).toContain("No markdown fences")
- })
-
- test("uses insert phrasing when selection is empty", () => {
- const result = buildSystemPrompt({ selectionEmpty: true })
- expect(result).toContain("empty")
- expect(result).not.toContain("replacement")
- })
-
- test("includes context files rule when hasContextFiles is true", () => {
- const result = buildSystemPrompt({ hasContextFiles: true })
- expect(result).toContain("[Context files]")
- })
-
- test("omits context files rule when hasContextFiles is false", () => {
- const result = buildSystemPrompt({ hasContextFiles: false })
- expect(result).not.toContain("[Context files]")
- })
-})
diff --git a/cli/tests/tokens.test.ts b/cli/tests/tokens.test.ts
deleted file mode 100644
index 31f3000..0000000
--- a/cli/tests/tokens.test.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { describe, test, expect } from "bun:test"
-import { estimateTokens, estimatePrompt } from "../src/tokens"
-
-describe("estimateTokens", () => {
- test("returns 0 for empty string", () => {
- expect(estimateTokens("")).toBe(0)
- })
-
- test("estimates short text", () => {
- // "hello world" = 11 chars → ceil(11/4) = 3
- expect(estimateTokens("hello world")).toBe(3)
- })
-
- test("estimates code block", () => {
- const code = "function foo(bar: string): number {\n return bar.length\n}"
- expect(estimateTokens(code)).toBe(Math.ceil(code.length / 4))
- })
-})
-
-describe("estimatePrompt", () => {
- test("returns only estimated_tokens when no context window", () => {
- const result = estimatePrompt("system prompt", "user message")
- expect(result).toHaveProperty("estimated_tokens")
- expect(result.context_window).toBeUndefined()
- expect(result.fill_pct).toBeUndefined()
- })
-
- test("sums tokens from both messages plus overhead", () => {
- const sys = "a".repeat(40) // 10 tokens
- const usr = "b".repeat(40) // 10 tokens
- const result = estimatePrompt(sys, usr)
- // 10 + 10 + 8 overhead = 28
- expect(result.estimated_tokens).toBe(28)
- })
-
- test("computes fill percentage when context window provided", () => {
- const result = estimatePrompt("a".repeat(400), "b".repeat(400), 1000)
- // 100 + 100 + 8 = 208 tokens, context_window = 1000
- // fill_pct = 20.8
- expect(result.fill_pct).toBe(20.8)
- expect(result.context_window).toBe(1000)
- })
-
- test("includes warning when exceeding context window", () => {
- const result = estimatePrompt("a".repeat(400), "b".repeat(400), 100)
- // 100 + 100 + 8 = 208 tokens, context_window = 100 → 208%
- expect(result.fill_pct).toBeGreaterThan(100)
- expect(result.warning).toBe("estimated prompt exceeds context window")
- })
-
- test("no warning when within context window", () => {
- const result = estimatePrompt("hello", "world", 128000)
- expect(result.warning).toBeUndefined()
- })
-})
diff --git a/cli/tsconfig.json b/cli/tsconfig.json
deleted file mode 100644
index cc85826..0000000
--- a/cli/tsconfig.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "compilerOptions": {
- "target": "ESNext",
- "module": "ESNext",
- "moduleResolution": "bundler",
- "strict": true,
- "types": ["bun-types"]
- }
-}
diff --git a/lua/patch/client.lua b/lua/patch/client.lua
new file mode 100644
index 0000000..c48ed90
--- /dev/null
+++ b/lua/patch/client.lua
@@ -0,0 +1,298 @@
+local M = {}
+local rpc = require("patch.rpc")
+
+local DEFAULT_SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences.\n"
+local system_prompt = DEFAULT_SYSTEM_PROMPT
+local configured_model
+local session_model
+local primary_model
+
+---@param callback function
+---@param value any
+---@param err string|nil
+local function schedule_complete(callback, value, err)
+ vim.schedule(function()
+ callback(value, err)
+ end)
+end
+
+---@class PatchClientOptions
+---@field system_prompt? string
+---@field model? string
+
+---@param opts? PatchClientOptions
+function M.setup(opts)
+ opts = opts or {}
+ system_prompt = opts.system_prompt or DEFAULT_SYSTEM_PROMPT
+ configured_model = opts.model and vim.trim(opts.model) or nil
+ configured_model = configured_model ~= "" and configured_model or nil
+ session_model = nil
+ primary_model = nil
+end
+
+---@class PatchModel
+---@field id string
+---@field name string
+---@field provider string
+
+---@class PatchRequest
+---@field state "pending"|"cancelled"|"completed"
+---@field connection? PatchRpcConnection
+---@field on_complete fun(res: string|nil, err: string|nil)
+
+--- Abort a Pi request.
+---
+---@param request PatchRequest
+---@return boolean cancelled
+function M.cancel(request)
+ if request.state ~= "pending" then
+ return false
+ end
+
+ request.state = "cancelled"
+
+ if request.connection then
+ request.connection:send({ type = "abort" })
+ end
+
+ vim.schedule(function()
+ request.on_complete(nil, "cancelled")
+ end)
+
+ return true
+end
+
+-- TODO: Cache available models and effective model state per session to avoid RPC startup latency when opening the menu.
+--- Retrieve the models available to Pi.
+---
+---@param on_complete fun(models: PatchModel[]|nil, err: string|nil)
+function M.get_available_models(on_complete)
+ rpc.request({ type = "get_available_models" }, function(record, err)
+ if err then
+ schedule_complete(on_complete, nil, err)
+ return
+ end
+
+ if type(record.data) ~= "table" or type(record.data.models) ~= "table" then
+ schedule_complete(on_complete, nil, "Pi returned an invalid model list")
+ return
+ end
+
+ schedule_complete(on_complete, record.data.models, nil)
+ end)
+end
+
+--- Use a model for the remainder of the current Neovim session.
+---
+---@param model PatchModel
+---@return string selector
+function M.select_model(model)
+ session_model = model.provider .. "/" .. model.id
+ return session_model
+end
+
+--- Resolve the effective model selector used by Patch.
+---
+---@param on_complete fun(model: string|nil, err: string|nil)
+function M.resolve_model(on_complete)
+ local override = session_model or configured_model
+ if override then
+ schedule_complete(on_complete, override, nil)
+ return
+ end
+
+ if primary_model then
+ schedule_complete(on_complete, primary_model, nil)
+ return
+ end
+
+ rpc.request({ type = "get_state" }, function(record, err)
+ if err then
+ schedule_complete(on_complete, nil, err)
+ return
+ end
+
+ local model = type(record.data) == "table" and record.data.model or nil
+ if type(model) ~= "table" or type(model.provider) ~= "string" or type(model.id) ~= "string" then
+ schedule_complete(on_complete, nil, "Pi returned no primary model")
+ return
+ end
+
+ primary_model = model.provider .. "/" .. model.id
+ schedule_complete(on_complete, primary_model, nil)
+ end)
+end
+
+--- Send a prompt to Pi and deliver its final assistant text.
+---
+--- @param message string prompt containing the selection and surrounding context
+--- @param on_complete fun(res: string|nil, err: string|nil)
+--- @return PatchRequest request
+function M.request(message, on_complete)
+ local request = {
+ state = "pending",
+ connection = nil,
+ on_complete = on_complete,
+ }
+
+ local assistant_message
+
+ --- Complete this request exactly once on Neovim's main event loop.
+ ---
+ --- @param res string|nil
+ --- @param err string|nil
+ local function complete(res, err)
+ if request.state ~= "pending" then
+ return
+ end
+
+ request.state = "completed"
+
+ vim.schedule(function()
+ request.on_complete(res, err)
+ end)
+ end
+
+ --- Report a request failure and complete it.
+ ---
+ --- @param err string
+ local function fail(err)
+ if request.state ~= "pending" then
+ return
+ end
+
+ complete(nil, err)
+ end
+
+ --- Join the text parts of an assistant message.
+ ---
+ --- @param message table Pi assistant message
+ --- @return string text
+ local function extract_text(message)
+ local parts = {}
+
+ for _, content in ipairs(message.content) do
+ if content.type == "text" then
+ table.insert(parts, content.text)
+ end
+ end
+
+ return table.concat(parts, "\n")
+ end
+
+ --- Clear references to this request's connection.
+ local function clear_connection()
+ request.connection = nil
+ end
+
+ --- Close the connection and clear its references.
+ local function close_connection()
+ local connection = request.connection
+
+ clear_connection()
+
+ if connection then
+ connection:close()
+ end
+ end
+
+ --- Report an unexpected process failure and release the process.
+ ---
+ --- @param result vim.SystemCompleted
+ local function handle_exit(result)
+ clear_connection()
+
+ if request.state == "pending" then
+ if result.code ~= 0 then
+ fail(rpc.format_exit_error(result))
+ else
+ fail("Pi exited before returning a response")
+ end
+ end
+ end
+
+ --- Handle a decoded record from Pi's JSONL output.
+ ---
+ --- @param record table
+ local function handle_record(record)
+ if request.state == "cancelled" then
+ if record.type == "agent_settled" then
+ close_connection()
+ end
+
+ return
+ end
+
+ if record.type == "response" and record.command == "prompt" and not record.success then
+ fail("Pi rejected the prompt: " .. record.error)
+ close_connection()
+ return
+ end
+
+ if record.type == "message_end" and record.message.role == "assistant" then
+ -- Pi may retry failed turns, so judge only the final message once settled.
+ assistant_message = record.message
+ return
+ end
+
+ if record.type == "agent_settled" then
+ if assistant_message == nil then
+ fail("Pi returned no response")
+ elseif assistant_message.stopReason == "error" then
+ local error_message = assistant_message.errorMessage
+ if type(error_message) ~= "string" or error_message == "" then
+ error_message = "Pi request failed"
+ end
+
+ fail(error_message)
+ else
+ -- Empty successful output is a valid deletion replacement.
+ complete(extract_text(assistant_message), nil)
+ end
+
+ close_connection()
+ return
+ end
+ end
+
+ local args = { "--system-prompt", system_prompt, "--no-session", "--thinking", "off" }
+ local model = session_model or configured_model
+
+ if model then
+ table.insert(args, "--model")
+ table.insert(args, model)
+ end
+
+ local connection, start_error = rpc.start(args, {
+ on_record = handle_record,
+ on_error = function(err)
+ if request.state == "pending" then
+ fail(err)
+ end
+
+ close_connection()
+ end,
+ on_exit = handle_exit,
+ })
+
+ if not connection then
+ fail(tostring(start_error))
+ return request
+ end
+
+ request.connection = connection
+
+ local sent, write_error = connection:send({
+ type = "prompt",
+ message = message,
+ })
+
+ if not sent then
+ fail("Failed to send prompt to Pi: " .. tostring(write_error))
+ close_connection()
+ end
+
+ return request
+end
+
+return M
diff --git a/lua/patch/context.lua b/lua/patch/context.lua
new file mode 100644
index 0000000..dba2526
--- /dev/null
+++ b/lua/patch/context.lua
@@ -0,0 +1,27 @@
+local M = {}
+
+local selection = require("patch.selection")
+
+--- @class PatchContent
+--- @field before string[] lines before the selection
+--- @field selected string[] selected lines
+--- @field after string[] lines after the selection
+
+--- Capture the current buffer contents around a tracked selection.
+---
+--- @param location PatchLocation
+--- @return PatchContent|nil content
+function M.capture(location)
+ local range = selection.resolve(location)
+ if not range then
+ return nil
+ end
+
+ return {
+ before = vim.api.nvim_buf_get_lines(range.source_buf, 0, range.start_row, false),
+ selected = vim.api.nvim_buf_get_lines(range.source_buf, range.start_row, range.end_row, false),
+ after = vim.api.nvim_buf_get_lines(range.source_buf, range.end_row, -1, false),
+ }
+end
+
+return M
diff --git a/lua/patch/init.lua b/lua/patch/init.lua
new file mode 100644
index 0000000..67e30b6
--- /dev/null
+++ b/lua/patch/init.lua
@@ -0,0 +1,295 @@
+local prompt = require("patch.prompt")
+local selection = require("patch.selection")
+local context = require("patch.context")
+local client = require("patch.client")
+local replacement = require("patch.replacement")
+local ui = require("patch.ui")
+local notify = require("patch.notify")
+
+local M = {}
+
+---@class PatchWorkflow
+---@field phase "input"|"generating"|"reviewing"|"retrying"
+---@field location PatchLocation
+---@field content? PatchContent
+---@field message? string
+---@field request? PatchRequest
+---@field proposal? PatchProposal
+
+---@type PatchWorkflow|nil
+local active_workflow
+
+---@class PatchOptions
+---@field notify? function|table notification provider compatible with vim.notify
+---@field system_prompt? string custom Pi system prompt
+---@field model? string Pi model selector in provider/model format
+
+---@param opts? PatchOptions
+function M.setup(opts)
+ opts = opts or {}
+ notify.setup(opts.notify)
+ client.setup({
+ system_prompt = opts.system_prompt,
+ model = opts.model,
+ })
+end
+
+---@param message string
+local function warn(message)
+ notify.send("patch: " .. message, vim.log.levels.WARN)
+end
+
+---@param message string
+local function report_error(message)
+ notify.send("patch: " .. message, vim.log.levels.ERROR)
+end
+
+---@param message string
+local function report_info(message)
+ notify.send("patch: " .. message, vim.log.levels.INFO)
+end
+
+---@param err string|nil
+local function report_request_error(err)
+ if err and err ~= "cancelled" then
+ report_error(err)
+ end
+end
+
+---@return boolean blocked
+local function block_menu_for_active_input()
+ if not active_workflow or active_workflow.phase ~= "input" then
+ return false
+ end
+
+ warn("submit or close the active instruction first")
+ return true
+end
+
+--- Open a menu containing the models available to Pi.
+function M.open_menu()
+ if block_menu_for_active_input() then
+ return
+ end
+
+ client.resolve_model(function(selected_model, model_error)
+ if block_menu_for_active_input() then
+ return
+ end
+
+ if model_error then
+ report_error(model_error)
+ return
+ end
+
+ client.get_available_models(function(models, err)
+ if block_menu_for_active_input() then
+ return
+ end
+
+ if err then
+ report_error(err)
+ return
+ end
+
+ if not models or #models == 0 then
+ warn("Pi reported no available models")
+ return
+ end
+
+ ui.open_menu(models, selected_model, function(model)
+ local selected = client.select_model(model)
+ report_info("using " .. selected)
+ end)
+ end)
+ end)
+end
+
+---@return boolean blocked
+local function block_start_for_active_workflow()
+ if not active_workflow then
+ return false
+ end
+
+ if active_workflow.phase == "input" then
+ warn("submit or close the active instruction first")
+ elseif active_workflow.phase == "reviewing" then
+ warn("accept or reject the active proposal first")
+ else
+ warn("a replacement is already being generated")
+ end
+
+ return true
+end
+
+--- Run a generation request and return the workflow to review when it succeeds.
+---
+---@param workflow PatchWorkflow
+---@param phase "generating"|"retrying"
+---@param apply_response fun(response: string): PatchProposal|nil, string|nil
+---@param abort fun()
+local function generate(workflow, phase, apply_response, abort)
+ workflow.phase = phase
+ report_info("generating...")
+
+ local request
+ request = client.request(workflow.message, function(response, err)
+ if active_workflow ~= workflow
+ or workflow.phase ~= phase
+ or workflow.request ~= request then
+ return
+ end
+
+ workflow.request = nil
+
+ if err then
+ abort()
+ active_workflow = nil
+ report_request_error(err)
+ return
+ end
+
+ local completed, completion_error = apply_response(response)
+ if not completed then
+ abort()
+ active_workflow = nil
+ report_error(tostring(completion_error))
+ return
+ end
+
+ workflow.phase = "reviewing"
+ report_info("complete")
+ end)
+ workflow.request = request
+end
+
+--- Capture a visual selection, request a replacement, and preview it at the tracked range.
+function M.start()
+ if block_start_for_active_workflow() then
+ return
+ end
+
+ local location = selection.capture()
+ if not location then
+ warn("no selection found")
+ return
+ end
+
+ local content = context.capture(location)
+ if not content then
+ selection.clear(location)
+ warn("no selection found")
+ return
+ end
+
+ local workflow = {
+ phase = "input",
+ location = location,
+ content = content,
+ }
+ active_workflow = workflow
+
+ ui.open_input(function(instruction)
+ if active_workflow ~= workflow or workflow.phase ~= "input" then
+ return
+ end
+
+ workflow.message = prompt.build(workflow.content, instruction)
+ workflow.content = nil
+
+ generate(workflow, "generating", function(response)
+ local proposal = replacement.apply(workflow.location, response)
+ if not proposal then
+ return nil, "selection no longer exists"
+ end
+
+ workflow.proposal = proposal
+ return proposal
+ end, function()
+ selection.clear(workflow.location)
+ end)
+ end, function()
+ if active_workflow ~= workflow or workflow.phase ~= "input" then
+ return
+ end
+
+ active_workflow = nil
+ selection.clear(workflow.location)
+ end)
+end
+
+--- Accept the active replacement proposal.
+function M.accept()
+ local workflow = active_workflow
+ if not workflow or not workflow.proposal then
+ warn("no active proposal to accept")
+ return
+ end
+
+ if replacement.accept(workflow.proposal) then
+ active_workflow = nil
+ return
+ end
+
+ if workflow.proposal.status == "finished" then
+ -- The proposal was finalized because its source buffer is unavailable.
+ active_workflow = nil
+ end
+
+ warn("the active proposal cannot be accepted")
+end
+
+--- Reject the active replacement proposal.
+function M.reject()
+ local workflow = active_workflow
+ if not workflow or not workflow.proposal then
+ warn("no active proposal to reject")
+ return
+ end
+
+ if replacement.reject(workflow.proposal) then
+ active_workflow = nil
+ else
+ warn("the active proposal cannot be rejected")
+ end
+end
+
+--- Request another replacement for the active proposal.
+function M.retry()
+ local workflow = active_workflow
+ if not workflow or not workflow.proposal or not workflow.message then
+ warn("no active proposal to retry")
+ return
+ end
+
+ if not replacement.begin_retry(workflow.proposal) then
+ if workflow.phase == "retrying" then
+ warn("the active proposal cannot be retried")
+ return
+ end
+
+ -- The proposal was finalized because its source buffer is unavailable.
+ active_workflow = nil
+ warn("the active proposal cannot be retried")
+ return
+ end
+
+ generate(workflow, "retrying", function(response)
+ return replacement.complete_retry(workflow.proposal, response)
+ end, function()
+ replacement.abort_retry(workflow.proposal)
+ end)
+end
+
+--- Cancel the active replacement request, if one exists.
+function M.cancel()
+ local workflow = active_workflow
+ if not workflow or not workflow.request or not client.cancel(workflow.request) then
+ warn("nothing to cancel")
+ return
+ end
+
+ report_info("cancelled")
+end
+
+return M
diff --git a/lua/patch/notify.lua b/lua/patch/notify.lua
new file mode 100644
index 0000000..dae5889
--- /dev/null
+++ b/lua/patch/notify.lua
@@ -0,0 +1,19 @@
+local M = {}
+
+local provider = nil
+
+---@param notify_provider function|table|nil
+function M.setup(notify_provider)
+ provider = notify_provider
+end
+
+---@param message string
+---@param level integer
+---@param opts? table
+function M.send(message, level, opts)
+ opts = vim.tbl_extend("keep", opts or {}, { title = "patch.nvim" })
+ local notifier = provider or vim.notify
+ notifier(message, level, opts)
+end
+
+return M
diff --git a/lua/patch/prompt.lua b/lua/patch/prompt.lua
new file mode 100644
index 0000000..48f2ed4
--- /dev/null
+++ b/lua/patch/prompt.lua
@@ -0,0 +1,37 @@
+local M = {}
+
+--- Format a named capture group for display.
+---
+--- @param name string group name
+--- @param lines string[] captured lines
+--- @return string formatted_group
+local function format_group(name, lines)
+ local contents = #lines > 0 and table.concat(lines, "\n") or "(empty)"
+ return string.format("--- %s ---\n%s", name, contents)
+end
+
+--- Format buffer context for display or inclusion in a prompt.
+---
+--- @param content PatchContent captured buffer context
+--- @return string formatted_context
+function M.format_context(content)
+ return table.concat({
+ format_group("BEFORE", content.before),
+ format_group("SELECTED", content.selected),
+ format_group("AFTER", content.after),
+ }, "\n")
+end
+
+--- Build a model prompt from captured buffer context and the user's instruction.
+---
+--- @param content PatchContent captured buffer context
+--- @param instruction string requested transformation
+--- @return string message
+function M.build(content, instruction)
+ return table.concat({
+ M.format_context(content),
+ string.format("--- INSTRUCTION ---\n%s", instruction),
+ }, "\n")
+end
+
+return M
diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua
new file mode 100644
index 0000000..5824145
--- /dev/null
+++ b/lua/patch/replacement.lua
@@ -0,0 +1,286 @@
+local M = {}
+local selection = require("patch.selection")
+
+local namespace = vim.api.nvim_create_namespace("patch-replacement")
+local previews = {}
+
+---@class PatchProposal
+---@field location PatchLocation
+---@field generated_mark integer|nil
+---@field generated_lines string[]
+---@field status "pending"|"retrying"|"finished"
+
+--- Convert model output into lines accepted by nvim_buf_set_lines, excluding a terminal newline from creating a new buffer line.
+---
+---@param response string
+---@return string[] lines
+local function to_lines(response)
+ local stripped = response:gsub("\r?\n$", "")
+
+ if stripped == "" then
+ return {}
+ end
+
+ return vim.split(stripped, "\n", { plain = true })
+end
+
+---@param proposal PatchProposal
+---@return boolean rendered
+local function render_preview(proposal)
+ local range = selection.resolve(proposal.location)
+ if not range then
+ return false
+ end
+
+ local source_buf = range.source_buf
+ if not selection.decorate(proposal.location, {
+ hl_group = "DiffDelete",
+ hl_eol = true,
+ }) then
+ return false
+ end
+
+ if #proposal.generated_lines == 0 then
+ if proposal.generated_mark then
+ vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark)
+ proposal.generated_mark = nil
+ end
+
+ return true
+ end
+
+ -- Neovim clips the highlighted padding at each window edge, matching hl_eol.
+ local padding = string.rep(" ", vim.o.columns)
+ local virtual_lines = {}
+
+ for index, line in ipairs(proposal.generated_lines) do
+ virtual_lines[index] = {
+ { line, "DiffAdd" },
+ { padding, "DiffAdd" },
+ }
+ end
+
+ local options = {
+ right_gravity = true,
+ virt_lines = virtual_lines,
+ }
+
+ if proposal.generated_mark then
+ options.id = proposal.generated_mark
+ end
+
+ proposal.generated_mark = vim.api.nvim_buf_set_extmark(
+ source_buf,
+ namespace,
+ math.max(range.end_row - 1, 0),
+ 0,
+ options
+ )
+
+ return true
+end
+
+local group = vim.api.nvim_create_augroup("patch-replacement", { clear = true })
+vim.api.nvim_create_autocmd("VimResized", {
+ group = group,
+ callback = function()
+ for _, proposal in pairs(previews) do
+ if proposal.status == "pending" then
+ render_preview(proposal)
+ end
+ end
+ end,
+})
+
+--- Remove a proposal's decorations while retaining its tracked selection.
+---
+---@param proposal PatchProposal
+---@return boolean hidden
+local function hide_preview(proposal)
+ local range = selection.resolve(proposal.location)
+ if not range then
+ return false
+ end
+
+ local source_buf = range.source_buf
+
+ if proposal.generated_mark then
+ vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark)
+ proposal.generated_mark = nil
+ end
+
+ if not selection.decorate(proposal.location) then
+ return false
+ end
+
+ proposal.generated_lines = {}
+ return true
+end
+
+---@param proposal PatchProposal
+local function clear_preview(proposal)
+ local source_buf = proposal.location.source_buf
+
+ if previews[source_buf] == proposal then
+ previews[source_buf] = nil
+ end
+
+ if proposal.generated_mark and vim.api.nvim_buf_is_loaded(source_buf) then
+ vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark)
+ end
+
+ proposal.generated_mark = nil
+ selection.clear(proposal.location)
+end
+
+--- Preview a generated replacement after a tracked selection.
+---
+---@param location PatchLocation
+---@param response string
+---@return PatchProposal|nil proposal
+function M.apply(location, response)
+ local range = selection.resolve(location)
+ if not range then
+ return nil
+ end
+
+ local proposal = {
+ location = location,
+ generated_mark = nil,
+ generated_lines = to_lines(response),
+ status = "pending",
+ }
+
+ previews[location.source_buf] = proposal
+ if not render_preview(proposal) then
+ previews[location.source_buf] = nil
+ return nil
+ end
+
+ return proposal
+end
+
+--- Replace a proposal's generated preview with a new response.
+---
+---@param proposal PatchProposal
+---@param response string
+---@return PatchProposal|nil proposal
+---@return string|nil error_message
+function M.update(proposal, response)
+ local original_range = selection.resolve(proposal.location)
+ if not original_range then
+ return nil, "original selection no longer exists"
+ end
+
+ proposal.generated_lines = to_lines(response)
+ if not render_preview(proposal) then
+ return nil, "original selection no longer exists"
+ end
+
+ return proposal
+end
+
+--- Accept a proposal by replacing the original selected lines, finalising it if the source buffer is no longer available.
+---
+---@param proposal PatchProposal
+---@return boolean accepted
+function M.accept(proposal)
+ if proposal.status ~= "pending" then
+ return false
+ end
+
+ local range = selection.resolve(proposal.location)
+ if not range then
+ proposal.status = "finished"
+ clear_preview(proposal)
+ return false
+ end
+
+ local source_buf = proposal.location.source_buf
+
+ local ok = pcall(
+ vim.api.nvim_buf_set_lines,
+ source_buf,
+ range.start_row,
+ range.end_row,
+ false,
+ proposal.generated_lines
+ )
+
+ if not ok then
+ return false
+ end
+
+ proposal.status = "finished"
+ clear_preview(proposal)
+ return true
+end
+
+--- Reject a proposal by removing its preview.
+---
+---@param proposal PatchProposal
+---@return boolean rejected
+function M.reject(proposal)
+ if proposal.status ~= "pending" then
+ return false
+ end
+
+ proposal.status = "finished"
+ clear_preview(proposal)
+ return true
+end
+
+--- Hide a proposal while another response is requested, finalising it if its tracked selection no longer exists.
+---
+---@param proposal PatchProposal
+---@return boolean started
+function M.begin_retry(proposal)
+ if proposal.status ~= "pending" then
+ return false
+ end
+
+ if not hide_preview(proposal) then
+ proposal.status = "finished"
+ clear_preview(proposal)
+ return false
+ end
+
+ proposal.status = "retrying"
+ return true
+end
+
+--- Render a successful retry response.
+---
+---@param proposal PatchProposal
+---@param response string
+---@return PatchProposal|nil proposal
+---@return string|nil error_message
+function M.complete_retry(proposal, response)
+ if proposal.status ~= "retrying" then
+ return nil, "proposal is not being retried"
+ end
+
+ local updated, update_error = M.update(proposal, response)
+ if not updated then
+ return nil, update_error
+ end
+
+ proposal.status = "pending"
+ return proposal
+end
+
+--- Remove a proposal whose retry did not complete.
+---
+---@param proposal PatchProposal
+---@return boolean aborted
+function M.abort_retry(proposal)
+ if proposal.status ~= "retrying" then
+ return false
+ end
+
+ proposal.status = "finished"
+ clear_preview(proposal)
+ return true
+end
+
+return M
diff --git a/lua/patch/rpc.lua b/lua/patch/rpc.lua
new file mode 100644
index 0000000..81f9ccf
--- /dev/null
+++ b/lua/patch/rpc.lua
@@ -0,0 +1,236 @@
+local M = {}
+
+---@class PatchRpcConnection
+---@field process? vim.SystemObj
+---@field closed boolean
+---@field send fun(self: PatchRpcConnection, record: table): boolean, string|nil
+---@field close fun(self: PatchRpcConnection)
+
+---@class PatchRpcHandlers
+---@field on_record fun(record: table)
+---@field on_error fun(err: string)
+---@field on_exit fun(result: vim.SystemCompleted)
+
+--- Create a callback that decodes newline-delimited JSON records.
+---
+---@param on_record fun(record: table)
+---@param on_error fun(err: string)
+---@return fun(error_message: string|nil, data: string|nil)
+local function read_jsonl(on_record, on_error)
+ local buffer = ""
+ local stopped = false
+
+ local function emit(line)
+ line = line:gsub("\r$", "")
+ if line == "" then
+ return true
+ end
+
+ local decoded, record = pcall(vim.json.decode, line)
+ if not decoded then
+ stopped = true
+ on_error("Failed to decode Pi output: " .. record)
+ return false
+ end
+
+ on_record(record)
+ return true
+ end
+
+ return function(error_message, data)
+ if stopped then
+ return
+ end
+
+ if error_message then
+ stopped = true
+ on_error("Failed to read Pi output: " .. error_message)
+ return
+ end
+
+ buffer = buffer .. (data or "")
+
+ while true do
+ local newline = buffer:find("\n", 1, true)
+ if not newline then
+ break
+ end
+
+ local line = buffer:sub(1, newline - 1)
+ buffer = buffer:sub(newline + 1)
+
+ if not emit(line) then
+ return
+ end
+ end
+
+ if data == nil and buffer ~= "" then
+ local line = buffer
+ buffer = ""
+ emit(line)
+ end
+ end
+end
+
+--- Format a non-zero Pi process result, including captured stderr when available.
+---
+---@param result vim.SystemCompleted
+---@return string message
+function M.format_exit_error(result)
+ local message = "Pi exited with code " .. result.code
+ local stderr = vim.trim(result.stderr or "")
+
+ if stderr ~= "" then
+ return message .. ": " .. stderr
+ end
+
+ return message
+end
+
+--- Start a Pi RPC connection.
+---
+---@param args string[] additional Pi command arguments
+---@param handlers PatchRpcHandlers
+---@return PatchRpcConnection|nil connection
+---@return string|nil error_message
+function M.start(args, handlers)
+ local connection = {
+ process = nil,
+ closed = false,
+ }
+
+ function connection:send(record)
+ if self.closed or not self.process then
+ return false, "Pi connection is closed"
+ end
+
+ local sent, write_error = pcall(function()
+ self.process:write(vim.json.encode(record) .. "\n")
+ end)
+
+ if not sent then
+ return false, tostring(write_error)
+ end
+
+ return true, nil
+ end
+
+ function connection:close()
+ if self.closed then
+ return
+ end
+
+ self.closed = true
+ local process = self.process
+ self.process = nil
+
+ if process then
+ pcall(process.write, process, nil)
+ end
+ end
+
+ local function handle_error(err)
+ connection:close()
+ handlers.on_error(err)
+ end
+
+ local handle_stdout = read_jsonl(handlers.on_record, handle_error)
+ local command = {
+ "pi",
+ "--mode", "rpc",
+ "--no-context-files",
+ "--no-skills",
+ "--no-extensions",
+ "--no-prompt-templates",
+ "--no-themes",
+ "--no-tools",
+ "--no-approve",
+ "--append-system-prompt", "",
+ }
+ vim.list_extend(command, args)
+
+ local started, process_or_error = pcall(
+ vim.system,
+ command,
+ { stdin = true, stdout = handle_stdout },
+ function(result)
+ connection.process = nil
+ connection.closed = true
+ handlers.on_exit(result)
+ end
+ )
+
+ if not started then
+ connection.closed = true
+ return nil, "Failed to start Pi: " .. tostring(process_or_error)
+ end
+
+ connection.process = process_or_error
+ return connection, nil
+end
+
+--- Run a one-shot Pi RPC command and return its response.
+---
+---@param command table
+---@param on_complete fun(record: table|nil, err: string|nil)
+function M.request(command, on_complete)
+ command = vim.deepcopy(command)
+ command.id = command.id or command.type
+
+ local connection
+ local connection_error
+ local completed = false
+
+ local function complete(record, err)
+ if completed then
+ return
+ end
+
+ completed = true
+ if connection then
+ connection:close()
+ end
+ on_complete(record, err)
+ end
+
+ connection, connection_error = M.start({ "--no-session" }, {
+ on_record = function(record)
+ if record.type ~= "response" or record.command ~= command.type or record.id ~= command.id then
+ return
+ end
+
+ if not record.success then
+ complete(nil, "Pi rejected " .. command.type .. ": " .. tostring(record.error))
+ return
+ end
+
+ complete(record, nil)
+ end,
+ on_error = function(err)
+ complete(nil, err)
+ end,
+ on_exit = function(result)
+ if completed then
+ return
+ end
+
+ if result.code ~= 0 then
+ complete(nil, M.format_exit_error(result))
+ else
+ complete(nil, "Pi exited before returning " .. command.type)
+ end
+ end,
+ })
+
+ if not connection then
+ complete(nil, tostring(connection_error))
+ return
+ end
+
+ local sent, write_error = connection:send(command)
+ if not sent then
+ complete(nil, "Failed to send " .. command.type .. " to Pi: " .. tostring(write_error))
+ end
+end
+
+return M
diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua
new file mode 100644
index 0000000..7024d6b
--- /dev/null
+++ b/lua/patch/selection.lua
@@ -0,0 +1,139 @@
+local M = {}
+
+local namespace = vim.api.nvim_create_namespace("patch")
+
+--- @class PatchLocation
+--- @field source_buf integer source buffer handle
+--- @field extmark_id integer selection range extmark
+
+--- @class PatchRange
+--- @field source_buf integer source buffer handle
+--- @field start_row integer 0-indexed first row of the selection
+--- @field end_row integer 0-indexed exclusive end row of the selection
+
+--- Exit visual mode and reset the selection highlight.
+local function leave_visual_mode()
+ vim.api.nvim_feedkeys(
+ vim.api.nvim_replace_termcodes("", true, false, true),
+ "nx",
+ false
+ )
+end
+
+--- Return the normalised line range of the most recent visual selection.
+---
+--- @return integer|nil start_line 1-indexed first line of the selection
+--- @return integer|nil end_line 1-indexed last line of the selection
+local function get_selection_lines()
+ local _, start_line = unpack(vim.fn.getpos("'<"))
+ local _, end_line = unpack(vim.fn.getpos("'>"))
+
+ if start_line == 0 then
+ return nil, nil
+ end
+
+ return math.min(start_line, end_line), math.max(start_line, end_line)
+end
+
+--- Track the current buffer's visual selection with an extmark that anchors its line range.
+---
+--- @return PatchLocation|nil location
+function M.capture()
+ local source_buf = vim.api.nvim_get_current_buf()
+ leave_visual_mode()
+
+ local start_line, end_line = get_selection_lines()
+ if not start_line then
+ return nil
+ end
+
+ local extmark_id = vim.api.nvim_buf_set_extmark(
+ source_buf,
+ namespace,
+ start_line - 1,
+ 0,
+ {
+ end_row = end_line,
+ end_col = 0,
+ right_gravity = true,
+ end_right_gravity = false,
+ }
+ )
+
+ return {
+ source_buf = source_buf,
+ extmark_id = extmark_id,
+ }
+end
+
+--- Resolve a tracked selection’s current line range, or nil if its source buffer is unloaded.
+---
+--- @param location PatchLocation
+--- @return PatchRange|nil range
+function M.resolve(location)
+ if not vim.api.nvim_buf_is_loaded(location.source_buf) then
+ return nil
+ end
+
+ local position = vim.api.nvim_buf_get_extmark_by_id(
+ location.source_buf,
+ namespace,
+ location.extmark_id,
+ { details = true }
+ )
+
+ if #position == 0 or not position[3] or position[3].end_row == nil then
+ return nil
+ end
+
+ return {
+ source_buf = location.source_buf,
+ start_row = position[1],
+ end_row = position[3].end_row,
+ }
+end
+
+--- Update a tracked selection's extmark decoration while preserving its range.
+---
+--- @param location PatchLocation
+--- @param decoration? table
+--- @return boolean updated
+function M.decorate(location, decoration)
+ local range = M.resolve(location)
+ if not range then
+ return false
+ end
+
+ local options = vim.tbl_extend("force", {
+ id = location.extmark_id,
+ end_row = range.end_row,
+ end_col = 0,
+ right_gravity = true,
+ end_right_gravity = false,
+ }, decoration or {})
+
+ vim.api.nvim_buf_set_extmark(
+ location.source_buf,
+ namespace,
+ range.start_row,
+ 0,
+ options
+ )
+
+ return true
+end
+
+--- Remove a tracked selection.
+---
+--- @param location PatchLocation
+function M.clear(location)
+ if vim.api.nvim_buf_is_loaded(location.source_buf) then
+ vim.api.nvim_buf_del_extmark(
+ location.source_buf,
+ namespace,
+ location.extmark_id
+ )
+ end
+end
+
+return M
diff --git a/lua/patch/ui/init.lua b/lua/patch/ui/init.lua
new file mode 100644
index 0000000..d27a3b2
--- /dev/null
+++ b/lua/patch/ui/init.lua
@@ -0,0 +1,23 @@
+local input = require("patch.ui.input")
+local menu = require("patch.ui.menu")
+
+local M = {}
+
+--- Open the instruction input and deliver its submitted value.
+---
+--- @param on_submit fun(instruction: string)
+--- @param on_close fun()
+function M.open_input(on_submit, on_close)
+ input.open(on_submit, on_close)
+end
+
+--- Open a menu containing Pi models.
+---
+---@param models PatchModel[]
+---@param selected_model string effective model selector
+---@param on_submit fun(model: PatchModel)
+function M.open_menu(models, selected_model, on_submit)
+ menu.open(models, selected_model, on_submit)
+end
+
+return M
diff --git a/lua/patch/ui/input.lua b/lua/patch/ui/input.lua
new file mode 100644
index 0000000..cff2c7f
--- /dev/null
+++ b/lua/patch/ui/input.lua
@@ -0,0 +1,61 @@
+local M = {}
+
+local Input = require("nui.input")
+local PROMPT = "> "
+
+local popup_options = {
+ relative = "cursor",
+ size = 50,
+ position = {
+ row = 2,
+ col = 1,
+ },
+ border = {
+ style = "single",
+ text = {
+ top = " Instruction ",
+ top_align = "left",
+ },
+ },
+ win_options = {
+ cursorline = true,
+ wrap = false,
+ }
+}
+
+---@param input NuiInput
+---@return string key
+local function submit_key(input)
+ local line = vim.api.nvim_buf_get_lines(input.bufnr, 0, 1, false)[1] or ""
+ local instruction = line:sub(#PROMPT + 1)
+ return instruction:find("%S") and "\r" or ""
+end
+
+--- Open the instruction input and deliver its submitted value.
+---
+--- @param on_submit fun(instruction: string)
+--- @param on_close fun()
+function M.open(on_submit, on_close)
+ local input = Input(vim.deepcopy(popup_options), {
+ prompt = PROMPT,
+ default_value = "",
+ on_submit = on_submit,
+ on_close = on_close,
+ })
+
+ for _, mode in ipairs({ "i", "n" }) do
+ input:map(mode, "", function()
+ return submit_key(input)
+ end, { expr = true, noremap = true })
+ end
+
+ -- unmount input by pressing `` in normal mode
+ input:map("n", "", function()
+ input:unmount()
+ end, { noremap = true })
+
+ input:mount()
+ vim.schedule(vim.cmd.startinsert)
+end
+
+return M
diff --git a/lua/patch/ui/menu.lua b/lua/patch/ui/menu.lua
new file mode 100644
index 0000000..b364fed
--- /dev/null
+++ b/lua/patch/ui/menu.lua
@@ -0,0 +1,75 @@
+local Menu = require("nui.menu")
+local event = require("nui.utils.autocmd").event
+
+local M = {}
+
+local popup_options = {
+ relative = "editor",
+ position = "50%",
+ size = {
+ width = 40,
+ },
+ padding = {
+ top = 1,
+ right = 2,
+ bottom = 1,
+ left = 2,
+ },
+ border = {
+ style = "single",
+ text = {
+ top = " Select model ",
+ top_align = "center",
+ },
+ },
+ win_options = {
+ winhighlight = "Normal:Normal",
+ },
+}
+
+--- Open a menu containing Pi models.
+---
+---@param models PatchModel[]
+---@param selected_model string effective model selector
+---@param on_submit fun(model: PatchModel)
+function M.open(models, selected_model, on_submit)
+ local lines = {}
+
+ for _, model in ipairs(models) do
+ local selector = model.provider .. "/" .. model.id
+ local marker = selector == selected_model and "● " or " "
+
+ table.insert(lines, Menu.item(marker .. model.id, {
+ model = model,
+ }))
+ end
+
+ local menu = Menu(popup_options, {
+ lines = lines,
+ max_width = 22,
+ keymap = {
+ focus_next = { "j", "", "" },
+ focus_prev = { "k", "", "" },
+ close = { "", "" },
+ submit = { "", "" },
+ },
+ on_submit = function(item)
+ on_submit(item.model)
+ end,
+ })
+
+ menu:on(event.BufLeave, function()
+ menu:unmount()
+ end)
+
+ menu:mount()
+
+ for row, model in ipairs(models) do
+ vim.api.nvim_buf_set_extmark(menu.bufnr, menu.ns_id, row - 1, 0, {
+ virt_text = { { model.provider, "Comment" } },
+ virt_text_pos = "right_align",
+ })
+ end
+end
+
+return M
diff --git a/lua/tau/context.lua b/lua/tau/context.lua
deleted file mode 100644
index a9f1af0..0000000
--- a/lua/tau/context.lua
+++ /dev/null
@@ -1,56 +0,0 @@
-local M = {}
-
---- Get full-line selection from the buffer.
---- Columns are intentionally discarded — selection is always treated as full lines.
---- @param bufnr integer
---- @param start_line integer 1-indexed
---- @param end_line integer 1-indexed, inclusive
---- @return string[] lines
-function M.get_selection(bufnr, start_line, end_line)
- -- nvim_buf_get_lines is 0-indexed, end is exclusive
- return vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false)
-end
-
---- Get N lines above and below the selection.
---- @param bufnr integer
---- @param start_line integer 1-indexed
---- @param end_line integer 1-indexed
---- @param context_lines integer number of surrounding lines to include
---- @return string above, string below
-function M.get_surrounding(bufnr, start_line, end_line, context_lines)
- local line_count = vim.api.nvim_buf_line_count(bufnr)
-
- local above_start = math.max(1, start_line - context_lines)
- local above_lines = vim.api.nvim_buf_get_lines(bufnr, above_start - 1, start_line - 1, false)
-
- local below_end = math.min(line_count, end_line + context_lines)
- local below_lines = vim.api.nvim_buf_get_lines(bufnr, end_line, below_end, false)
-
- return table.concat(above_lines, "\n"), table.concat(below_lines, "\n")
-end
-
---- Build full context table for a given selection range.
---- @param bufnr integer
---- @param start_line integer 1-indexed (from opts.line1 or mark)
---- @param end_line integer 1-indexed (from opts.line2 or mark)
---- @param context_lines integer number of surrounding lines to include
---- @return table
-function M.get(bufnr, start_line, end_line, context_lines)
- local selection_lines = M.get_selection(bufnr, start_line, end_line)
- local above, below = M.get_surrounding(bufnr, start_line, end_line, context_lines)
-
- return {
- selection = {
- lines = selection_lines,
- text = table.concat(selection_lines, "\n"),
- start_line = start_line,
- end_line = end_line,
- },
- above = above,
- below = below,
- filepath = vim.api.nvim_buf_get_name(bufnr),
- filetype = vim.bo[bufnr].filetype,
- }
-end
-
-return M
diff --git a/lua/tau/context_files.lua b/lua/tau/context_files.lua
deleted file mode 100644
index 1ba4a2e..0000000
--- a/lua/tau/context_files.lua
+++ /dev/null
@@ -1,76 +0,0 @@
--- Session-scoped context file state.
--- Selected files persist across :Tau calls but clear when Neovim exits.
-
-local M = {}
-
---- @type table
-local _files = {}
-
---- Normalize a path to absolute form.
---- @param path string
---- @return string
-local function normalize(path)
- return vim.fn.fnamemodify(path, ":p")
-end
-
---- Add a file to the context set.
---- @param path string
-function M.add(path)
- _files[normalize(path)] = true
-end
-
---- Remove a file from the context set.
---- @param path string
-function M.remove(path)
- _files[normalize(path)] = nil
-end
-
---- Toggle a file in/out of the context set.
---- @param path string
-function M.toggle(path)
- local abs = normalize(path)
- if _files[abs] then
- _files[abs] = nil
- else
- _files[abs] = true
- end
-end
-
---- Return sorted list of absolute paths currently in context.
---- @return string[]
-function M.get()
- local paths = vim.tbl_keys(_files)
- table.sort(paths)
- return paths
-end
-
---- Check if a file is in the context set.
---- @param path string
---- @return boolean
-function M.contains(path)
- return _files[normalize(path)] ~= nil
-end
-
---- Check if an already-normalized absolute path is in the context set.
---- @param abs string must be a normalized absolute path
---- @return boolean
-function M.contains_abs(abs)
- return _files[abs] ~= nil
-end
-
---- Toggle an already-normalized absolute path in/out of the context set.
---- @param abs string must be a normalized absolute path
-function M.toggle_abs(abs)
- if _files[abs] then
- _files[abs] = nil
- else
- _files[abs] = true
- end
-end
-
---- Clear all context files.
-function M.clear()
- _files = {}
-end
-
-return M
diff --git a/lua/tau/context_picker.lua b/lua/tau/context_picker.lua
deleted file mode 100644
index c049cd5..0000000
--- a/lua/tau/context_picker.lua
+++ /dev/null
@@ -1,240 +0,0 @@
--- Floating picker for selecting context files.
--- Shows open buffers + all files in cwd; toggles persist in context_files module.
-
-local context_files = require("tau.context_files")
-
-local M = {}
-
-local _open = false
-
-vim.api.nvim_set_hl(0, "TauContextCursorLine", { link = "CursorLine" })
-
-local function add_candidate(candidates, seen, path)
- local abs = vim.fn.fnamemodify(path, ":p")
- if seen[abs] or vim.fn.isdirectory(abs) ~= 0 or vim.fn.filereadable(abs) == 0 then return end
-
- seen[abs] = true
- candidates[#candidates + 1] = { abs = abs, rel = vim.fn.fnamemodify(abs, ":~:.") }
-end
-
---- List cwd files, respecting git ignore rules when cwd is inside a Git repo.
---- @return string[]
-local function list_cwd_files()
- local cwd = vim.loop.cwd() or vim.fn.getcwd()
-
- if vim.fn.executable("git") == 1 then
- local files = vim.fn.systemlist({ "git", "-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "--", "." })
- if vim.v.shell_error == 0 then
- return files
- end
- end
-
- -- Fallback for non-Git directories. This does not understand .gitignore.
- return vim.fn.glob("**/*", false, true)
-end
-
---- Collect candidate file paths, deduplicated and sorted.
---- @return {abs: string, rel: string}[]
-local function get_candidates()
- local seen = {}
- local candidates = {}
-
- -- Open buffers (listed, non-special)
- for _, buf in ipairs(vim.api.nvim_list_bufs()) do
- if vim.bo[buf].buflisted and vim.bo[buf].buftype == "" then
- local name = vim.api.nvim_buf_get_name(buf)
- if name ~= "" then
- add_candidate(candidates, seen, name)
- end
- end
- end
-
- -- Files in cwd. In Git repos this uses `git ls-files --exclude-standard`,
- -- so .gitignore/.git/info/exclude/global excludes are honored.
- for _, rel_path in ipairs(list_cwd_files()) do
- if rel_path ~= "" then
- add_candidate(candidates, seen, rel_path)
- end
- end
-
- table.sort(candidates, function(a, b) return a.abs < b.abs end)
- return candidates
-end
-
---- Format a single candidate line for display.
---- @param abs_path string
---- @param rel_path string pre-computed display path
---- @param current_file string|nil always-included locked file
---- @return string
-local function format_line(abs_path, rel_path, current_file)
- if abs_path == current_file then
- return " ◎ " .. rel_path
- elseif context_files.contains_abs(abs_path) then
- return " ● " .. rel_path
- else
- return " " .. rel_path
- end
-end
-
---- Open the context file picker.
---- @param opts? { on_close?: fun(), current_file?: string }
-function M.open(opts)
- opts = opts or {}
-
- if _open then
- if opts.on_close then opts.on_close() end
- return
- end
- _open = true
-
- local current_file = opts.current_file
-
- -- rel paths are captured once at open; display labels become stale if cwd changes while picker is open
- local ok, candidates = pcall(get_candidates)
- if not ok or #candidates == 0 then
- _open = false
- if not ok then
- vim.api.nvim_echo({ { "tau: failed to list files", "WarningMsg" } }, false, {})
- else
- vim.api.nvim_echo({ { "tau: no candidate files found", "WarningMsg" } }, false, {})
- end
- if opts.on_close then opts.on_close() end
- return
- end
-
- -- Geometry
- local width = math.max(50, math.min(80, vim.o.columns - 6))
- local height = math.min(#candidates, math.max(10, vim.o.lines - 10))
- local start_row = math.floor((vim.o.lines - height - 2) / 2)
- local start_col = math.floor((vim.o.columns - width - 2) / 2)
-
- -- Footer helpers (right side is constant; cache its display width)
- local footer_right = " toggle · close "
- local footer_right_width = vim.fn.strdisplaywidth(footer_right)
-
- local function build_footer(count)
- local left = count > 0 and (" " .. count .. " selected ") or ""
- local pad = width - vim.fn.strdisplaywidth(left) - footer_right_width
- if pad < 1 then pad = 1 end
- return left .. string.rep("─", pad) .. footer_right
- end
-
- -- Track selected count locally to avoid context_files.get() on every toggle
- local selected_count = 0
- for _, p in ipairs(context_files.get()) do
- if p ~= current_file then selected_count = selected_count + 1 end
- end
-
- -- Buffer
- local buf = vim.api.nvim_create_buf(false, true)
- vim.bo[buf].bufhidden = "wipe"
- vim.bo[buf].buftype = "nofile"
-
- -- Window
- local win = vim.api.nvim_open_win(buf, true, {
- relative = "editor",
- row = start_row,
- col = start_col,
- width = width,
- height = height,
- border = "rounded",
- title = " Context Files ",
- title_pos = "left",
- footer = build_footer(selected_count),
- footer_pos = "left",
- style = "minimal",
- noautocmd = true,
- })
- vim.wo[win].cursorline = true
- vim.wo[win].winhighlight = "CursorLine:TauContextCursorLine"
-
- vim.cmd("stopinsert")
-
- -- Full render used only on initial draw
- local function render()
- local lines = {}
- for _, c in ipairs(candidates) do
- lines[#lines + 1] = format_line(c.abs, c.rel, current_file)
- end
- vim.bo[buf].modifiable = true
- vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
- vim.bo[buf].modifiable = false
-
- if vim.api.nvim_win_is_valid(win) then
- vim.api.nvim_win_set_config(win, { footer = build_footer(selected_count), footer_pos = "left" })
- end
- end
-
- render()
-
- local footer_update_pending = false
- local function schedule_footer_update()
- if footer_update_pending then return end
- footer_update_pending = true
- vim.schedule(function()
- footer_update_pending = false
- if vim.api.nvim_win_is_valid(win) then
- vim.api.nvim_win_set_config(win, { footer = build_footer(selected_count), footer_pos = "left" })
- end
- end)
- end
-
- -- Close helpers
- local closed = false
-
- local function close()
- if closed then return end
- closed = true
- _open = false
- if vim.api.nvim_win_is_valid(win) then
- vim.api.nvim_win_close(win, true)
- end
- if opts.on_close then
- vim.schedule(opts.on_close)
- end
- end
-
- -- Toggle the file under cursor
- local function toggle()
- local row = vim.api.nvim_win_get_cursor(win)[1]
- local c = candidates[row]
- if not c or c.abs == current_file then return end
-
- local was_selected = context_files.contains_abs(c.abs)
- context_files.toggle_abs(c.abs)
- selected_count = selected_count + (was_selected and -1 or 1)
-
- -- Update only the toggled line
- vim.bo[buf].modifiable = true
- vim.api.nvim_buf_set_lines(buf, row - 1, row, false, { format_line(c.abs, c.rel, current_file) })
- vim.bo[buf].modifiable = false
-
- if vim.api.nvim_win_is_valid(win) then
- vim.api.nvim_win_set_cursor(win, { row, 0 })
- end
-
- -- Deferred so the line repaint is not blocked by the window re-layout.
- -- Coalesce bursts of toggles into one footer reconfiguration per event-loop tick.
- schedule_footer_update()
- end
-
- -- Keymaps
- local mo = { buffer = buf, noremap = true, silent = true }
- -- Space is commonly used as ; without nowait Neovim waits for
- -- timeoutlen to see if a longer mapping follows before toggling.
- local toggle_mo = { buffer = buf, noremap = true, silent = true, nowait = true }
- vim.keymap.set("n", "", toggle, toggle_mo)
- vim.keymap.set("n", "", close, mo)
- vim.keymap.set("n", "q", close, mo)
-
- -- Close on WinLeave
- vim.api.nvim_create_autocmd("WinLeave", {
- buffer = buf,
- once = true,
- callback = function()
- vim.schedule(close)
- end,
- })
-end
-
-return M
diff --git a/lua/tau/history.lua b/lua/tau/history.lua
deleted file mode 100644
index e0f5acc..0000000
--- a/lua/tau/history.lua
+++ /dev/null
@@ -1,24 +0,0 @@
-local M = {}
-
-local MAX_HISTORY = 50
-local _history = {}
-
-function M.add(instruction)
- if not instruction or instruction == "" then return end
- for i = #_history, 1, -1 do
- if _history[i] == instruction then
- table.remove(_history, i)
- break
- end
- end
- table.insert(_history, 1, instruction)
- if #_history > MAX_HISTORY then table.remove(_history) end
-end
-
-function M.list()
- local copy = {}
- for i, v in ipairs(_history) do copy[i] = v end
- return copy
-end
-
-return M
diff --git a/lua/tau/init.lua b/lua/tau/init.lua
deleted file mode 100644
index ffa908d..0000000
--- a/lua/tau/init.lua
+++ /dev/null
@@ -1,357 +0,0 @@
-local context = require("tau.context")
-local runner = require("tau.runner")
-local ui = require("tau.ui")
-local history = require("tau.history")
-local picker = require("tau.picker")
-
-local M = {}
-
---- @type table
-local config = {}
-
-local SIGTERM = 15
-local NS_TRACK = vim.api.nvim_create_namespace("tau_track")
-
---- @type { handle: vim.SystemObj|nil, bufnr: integer, cancelled: boolean, prev_esc: table, mark_start: integer, mark_end: integer } | nil
-local _job = nil
-
---- @type { bufnr: integer, start_line: integer, end_line: integer, new_lines: string[], instruction: string } | nil
-local _pending = nil
-
---- True while a buf-attach listener is watching for in-region edits during pending review.
-local _watching = false
-
---- True while the instruction picker is open, to prevent stacked invocations.
-local _picking = false
-
---- Clear preview UI unconditionally — safe to call even if _job is nil.
-local function _clear_pending()
- if not _pending then return end
- ui.clear_preview(_pending.bufnr)
- pcall(vim.keymap.del, "n", "", { buffer = _pending.bufnr })
- pcall(vim.keymap.del, "n", "r", { buffer = _pending.bufnr })
- _pending = nil
-end
-
---- Read current selection boundaries from tracking extmarks.
---- Returns nil, nil if _job is absent or buffer is invalid.
---- @param bufnr integer
---- @return integer|nil, integer|nil 1-indexed start_line, end_line
-local function get_tracked_range(bufnr)
- if not _job then return nil, nil end
- if not vim.api.nvim_buf_is_valid(bufnr) then return nil, nil end
- local s = vim.api.nvim_buf_get_extmark_by_id(bufnr, NS_TRACK, _job.mark_start, {})
- local e = vim.api.nvim_buf_get_extmark_by_id(bufnr, NS_TRACK, _job.mark_end, {})
- if not s or #s == 0 or not e or #e == 0 then return nil, nil end
- return s[1] + 1, e[1] + 1
-end
-
---- Tear down all in-flight state: stop UI, restore mapping, unlock buffer.
---- @param bufnr integer
-local function _cleanup(bufnr)
- -- Clear preview before the _job guard so ghost extmarks are never left behind
- -- if _job has already been set to nil (e.g. double-accept or cancel race).
- _clear_pending()
- if not _job then return end
- ui.stop()
- pcall(vim.keymap.del, "n", "", { buffer = bufnr })
- if _job.prev_esc and _job.prev_esc.lhs and _job.prev_esc.lhs ~= "" then
- if vim.api.nvim_buf_is_valid(bufnr) then
- -- prev_esc is captured before any mapping is installed in _execute.
- -- Do not move that capture below any vim.keymap.set call or this restore breaks.
- vim.fn.mapset("n", false, _job.prev_esc)
- end
- end
- if vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_clear_namespace(bufnr, NS_TRACK, 0, -1)
- end
- _watching = false
- _job = nil
-end
-
-local function _accept()
- if not _pending then return end
- local p = _pending
- -- Read extmark positions BEFORE _cleanup() clears NS_TRACK
- local cur_start, cur_end = get_tracked_range(p.bufnr)
- local final_start = cur_start or p.start_line
- local final_end = cur_end or p.end_line
- -- _cleanup must precede nvim_buf_set_lines: it sets _watching = false so the
- -- buf-attach watcher ignores the programmatic write and does not emit a false cancel.
- _cleanup(p.bufnr)
- if not vim.api.nvim_buf_is_valid(p.bufnr) then
- vim.api.nvim_echo({ { "tau: buffer was closed, replacement lost", "ErrorMsg" } }, false, {})
- return
- end
- local ok, err = pcall(vim.api.nvim_buf_set_lines, p.bufnr, final_start - 1, final_end, false, p.new_lines)
- if not ok then
- vim.api.nvim_echo({ { "tau: failed to apply replacement: " .. tostring(err), "ErrorMsg" } }, false, {})
- end
-end
-
-local function _reject()
- if not _pending then return end
- _cleanup(_pending.bufnr)
-end
-
-local function _regen()
- if not _pending then return end
- local p = _pending
- local cur_start, cur_end = get_tracked_range(p.bufnr)
- local final_start = cur_start or p.start_line
- local final_end = cur_end or p.end_line
- _cleanup(p.bufnr)
- M._execute(p.bufnr, final_start, final_end, p.instruction)
-end
-
---- Configure the plugin. Must be called before using :Tau.
---- @param opts table { api_url: string, api_key: string, model?: string, debug?: boolean, timeout_ms?: number, context_window?: number, context_lines?: number, temperature?: number, max_tokens?: number, top_p?: number, keys?: { context?: string } }
-function M.setup(opts)
- vim.validate({
- api_url = { opts.api_url, "string" },
- api_key = { opts.api_key, "string" },
- model = { opts.model, "string", true },
- debug = { opts.debug, "boolean", true },
- timeout_ms = { opts.timeout_ms, "number", true },
- context_window = { opts.context_window, "number", true },
- context_lines = { opts.context_lines, "number", true },
- temperature = { opts.temperature, "number", true },
- max_tokens = { opts.max_tokens, "number", true },
- top_p = { opts.top_p, "number", true },
- keys = { opts.keys, "table", true },
- })
- if opts.context_lines ~= nil then
- if opts.context_lines < 1 or opts.context_lines ~= math.floor(opts.context_lines) then
- error("tau: context_lines must be a positive integer, got " .. opts.context_lines)
- end
- end
- if opts.temperature ~= nil and (opts.temperature < 0 or opts.temperature > 2) then
- error("tau: temperature must be between 0 and 2, got " .. opts.temperature)
- end
- if opts.max_tokens ~= nil then
- if opts.max_tokens < 1 or opts.max_tokens ~= math.floor(opts.max_tokens) then
- error("tau: max_tokens must be a positive integer, got " .. opts.max_tokens)
- end
- end
- if opts.top_p ~= nil and (opts.top_p < 0 or opts.top_p > 1) then
- error("tau: top_p must be between 0 and 1, got " .. opts.top_p)
- end
- opts.context_lines = opts.context_lines or 30
- opts.keys = vim.tbl_extend("keep", opts.keys or {}, { context = "" })
- config = opts
-end
-
---- Main entry point. Called from the :Tau command.
---- @param opts table vim command opts (line1, line2, args)
-function M.run(opts)
- if not config.api_url then
- vim.api.nvim_echo({ { "tau: call require('tau').setup() first", "ErrorMsg" } }, false, {})
- return
- end
-
- if _job or _picking then
- vim.api.nvim_echo({ { "tau: request already in flight — use :TauCancel first", "WarningMsg" } }, false, {})
- return
- end
-
- local bufnr = vim.api.nvim_get_current_buf()
-
- -- Capture selection line numbers synchronously before any async call.
- -- opts.line1/line2 are authoritative when the command is called with a range.
- -- Fall back to visual marks otherwise.
- local start_line = opts.line1 or vim.fn.line("'<")
- local end_line = opts.line2 or vim.fn.line("'>")
-
- if start_line == 0 or end_line == 0 then
- vim.api.nvim_echo({ { "tau: no selection", "WarningMsg" } }, false, {})
- return
- end
-
- -- Get instruction: from command args or history picker
- local instruction = opts.args and opts.args ~= "" and opts.args or nil
- local hist = history.list()
-
- if instruction then
- M._execute(bufnr, start_line, end_line, instruction)
- else
- _picking = true
- local _raw_name = vim.api.nvim_buf_get_name(bufnr)
- local current_file = _raw_name ~= "" and vim.fn.fnamemodify(_raw_name, ":p") or nil
- picker.open(hist, function(choice)
- _picking = false
- if not choice then return end
- M._execute(bufnr, start_line, end_line, choice)
- end, { context_key = config.keys.context, current_file = current_file })
- end
-end
-
---- Internal: execute the LLM replacement after instruction is known.
---- @param bufnr integer
---- @param start_line integer 1-indexed
---- @param end_line integer 1-indexed
---- @param instruction string
-function M._execute(bufnr, start_line, end_line, instruction)
- if _job then
- vim.api.nvim_echo({ { "tau: request already in flight", "WarningMsg" } }, false, {})
- return
- end
-
- local ctx = context.get(bufnr, start_line, end_line, config.context_lines)
-
- -- Place tracking extmarks to follow selection boundaries as user edits outside the region
- local mark_start = vim.api.nvim_buf_set_extmark(bufnr, NS_TRACK, start_line - 1, 0, {
- right_gravity = false,
- })
- local mark_end = vim.api.nvim_buf_set_extmark(bufnr, NS_TRACK, end_line - 1, 0, {
- right_gravity = true,
- })
-
- -- Start UI: spinner
- ui.start(bufnr, start_line)
-
- local accumulated = ""
- local token_meta = nil
-
- -- Pre-assign _job so callbacks always see a non-nil sentinel even if the
- -- process exits before vim.system returns on this tick. handle is filled in
- -- below after runner.run() returns.
- local prev_esc = vim.fn.maparg("", "n", false, true)
- _job = { handle = nil, bufnr = bufnr, cancelled = false, prev_esc = prev_esc,
- mark_start = mark_start, mark_end = mark_end }
-
- local handle = runner.run({
- config = config,
- instruction = instruction,
- selection_text = ctx.selection.text,
- context_above = ctx.above,
- context_below = ctx.below,
- filepath = ctx.filepath,
- filetype = ctx.filetype,
- context_files = require("tau.context_files").get(),
-
- on_meta = function(meta)
- token_meta = meta
- ui.update_meta(meta)
- if meta.warning then
- vim.api.nvim_echo({ { "tau: " .. meta.warning, "WarningMsg" } }, false, {})
- end
- end,
-
- on_token = function(chunk)
- accumulated = accumulated .. chunk
- vim.schedule(function()
- ui.update_progress(#accumulated)
- end)
- end,
-
- on_done = function()
- if not _job then return end
- if _job.cancelled then
- _cleanup(bufnr)
- return
- end
-
- history.add(instruction)
-
- -- Stop spinner; defer full cleanup until accept/reject
- ui.stop()
-
- local ok, err = pcall(function()
- -- Strip markdown code fences the model may wrap output in
- local text = accumulated:gsub("^%s*```[%w]*%s*\n", ""):gsub("\n%s*```%s*$", "")
- -- Remove only trailing whitespace, preserve leading indentation
- text = text:gsub("%s+$", "")
-
- local new_lines = vim.split(text, "\n", { plain = true })
-
- local cur_start, cur_end = get_tracked_range(bufnr)
- local final_start = cur_start or start_line
- local final_end = cur_end or end_line
-
- _pending = { bufnr = bufnr, start_line = final_start, end_line = final_end, new_lines = new_lines, instruction = instruction }
-
- -- Close over stable mark IDs so the callback has no dependency on _job,
- -- which may be nil if _cleanup races with a scheduled on_lines delivery.
- local watch_mark_start = _job.mark_start
- local watch_mark_end = _job.mark_end
- _watching = vim.api.nvim_buf_attach(bufnr, false, {
- on_lines = function(_, _, _, firstline, lastline)
- if not _watching then return true end -- detach
- local s = vim.api.nvim_buf_get_extmark_by_id(bufnr, NS_TRACK, watch_mark_start, {})
- local e = vim.api.nvim_buf_get_extmark_by_id(bufnr, NS_TRACK, watch_mark_end, {})
- if not s or #s == 0 or not e or #e == 0 then return true end
- -- extmark rows are 0-indexed; convert to half-open [sel_start, sel_end) to match on_lines ranges
- local sel_start = s[1]
- local sel_end = e[1] + 1 -- mark_end sits on last selected row; +1 makes upper bound exclusive
- -- [firstline, lastline) ∩ [sel_start, sel_end) ≠ ∅ ↔ firstline < sel_end and lastline > sel_start
- if firstline < sel_end and lastline > sel_start then
- vim.schedule(function()
- if not _pending then return end
- _cleanup(bufnr)
- vim.api.nvim_echo(
- { { "tau: selection modified — review cancelled", "WarningMsg" } },
- false, {}
- )
- end)
- return true -- detach
- end
- end,
- })
-
- ui.show_preview(bufnr, final_start, final_end, new_lines, instruction, token_meta)
-
- -- now rejects instead of cancels; accepts
- vim.keymap.set("n", "", _reject,
- { buffer = bufnr, noremap = true, silent = true, desc = "tau: reject replacement" })
- vim.keymap.set("n", "", _accept,
- { buffer = bufnr, noremap = true, silent = true, desc = "tau: accept replacement" })
- vim.keymap.set("n", "r", _regen,
- { buffer = bufnr, noremap = true, silent = true, desc = "tau: regen replacement" })
- end)
- if not ok then
- _cleanup(bufnr)
- vim.api.nvim_echo({ { "tau: " .. tostring(err), "ErrorMsg" } }, false, {})
- end
- end,
-
- on_error = function(msg)
- if not _job then return end
- local was_cancelled = _job.cancelled
- _cleanup(bufnr)
-
- if was_cancelled then return end
-
- local ok, err = pcall(function()
- ui.error(bufnr, start_line, msg)
- end)
- if not ok then
- vim.api.nvim_echo({ { "tau: " .. tostring(err), "ErrorMsg" } }, false, {})
- end
- end,
- })
-
- _job.handle = handle
-
- vim.keymap.set("n", "", function()
- require("tau").cancel()
- end, { buffer = bufnr, noremap = true, silent = true, desc = "tau: cancel request" })
-end
-
---- Cancel the in-flight request, if any.
-function M.cancel()
- if not _job then
- vim.api.nvim_echo({ { "tau: no request in flight", "Comment" } }, false, {})
- return
- end
-
- local j = _job
- j.cancelled = true -- set before kill so the exit callback sees it
- if j.handle then
- j.handle:kill(SIGTERM) -- process may exit 0 or non-zero; cancelled flag handles both
- end
- _cleanup(j.bufnr) -- immediate UI teardown; don't wait for the callback
-
- vim.api.nvim_echo({ { "tau: cancelled", "Comment" } }, false, {})
-end
-
-return M
diff --git a/lua/tau/picker.lua b/lua/tau/picker.lua
deleted file mode 100644
index 2751b0d..0000000
--- a/lua/tau/picker.lua
+++ /dev/null
@@ -1,274 +0,0 @@
--- Native instruction picker — compact single-box layout.
--- NOTE: When the Telescope UI provider (#45) is implemented, it must replicate
--- this layout spec: title top-left, single-line input, inline history cycling,
--- and keybinding hints in the bottom-right border.
-
-local context_files = require("tau.context_files")
-
-local M = {}
-
-local _aug_id = 0
-
---- @param history string[] ordered newest-first
---- @param on_choice fun(choice: string|nil)
---- @param opts? { context_key?: string, current_file?: string }
-function M.open(history, on_choice, opts)
- local context_key = opts and opts.context_key or ""
- local current_file = opts and opts.current_file
- local closed = false
- local cycling = false
- local context_open = false
-
- _aug_id = _aug_id + 1
- local aug_name = "tau_picker_" .. _aug_id
-
- -- Geometry
- local width = math.max(40, math.min(70, vim.o.columns - 6))
- local height = 1
- local start_row = math.floor((vim.o.lines - height - 2) / 2)
- local start_col = math.floor((vim.o.columns - width - 2) / 2)
-
- -- Buffer
- local buf = vim.api.nvim_create_buf(false, true)
- vim.bo[buf].bufhidden = "wipe"
- vim.bo[buf].buftype = "nofile"
-
- -- Window
- local footer = " ⇅ reuse "
- local win = vim.api.nvim_open_win(buf, true, {
- relative = "editor",
- row = start_row,
- col = start_col,
- width = width,
- height = height,
- border = "rounded",
- title = " Instruction ",
- title_pos = "left",
- footer = footer,
- footer_pos = "right",
- style = "minimal",
- noautocmd = true,
- })
-
- -- "In Context" display window (appears below when files are selected)
- local ctx_win = nil
- local ctx_buf = nil
-
- local function build_context_lines()
- local files = context_files.get()
- if #files == 0 then return nil end
-
- local sep = " "
- local sep_w = vim.fn.strdisplaywidth(sep)
- local inner = width - 3 -- border left (1) + border right (1) + leading space prefix (1)
- local lines = {}
- local col = 0
- local current = ""
-
- for _, p in ipairs(files) do
- local name = vim.fn.fnamemodify(p, ":t")
- local nw = vim.fn.strdisplaywidth(name)
- local prefix = col > 0 and sep or ""
- local pw = (col > 0 and sep_w or 0) + nw
-
- if col > 0 and col + pw > inner then
- lines[#lines + 1] = " " .. current
- current = name
- col = nw
- else
- current = current .. prefix .. name
- col = col + pw
- end
- end
- if current ~= "" then
- lines[#lines + 1] = " " .. current
- end
-
- return lines
- end
-
- local function refresh_ctx_win()
- local lines = build_context_lines()
-
- if not lines then
- if ctx_win and vim.api.nvim_win_is_valid(ctx_win) then
- vim.api.nvim_win_close(ctx_win, true)
- ctx_win = nil
- ctx_buf = nil
- end
- return
- end
-
- local ctx_height = #lines
-
- if not ctx_win or not vim.api.nvim_win_is_valid(ctx_win) then
- ctx_buf = vim.api.nvim_create_buf(false, true)
- vim.bo[ctx_buf].bufhidden = "wipe"
- vim.bo[ctx_buf].buftype = "nofile"
- local ok, result = pcall(vim.api.nvim_open_win, ctx_buf, false, {
- relative = "editor",
- row = start_row + height + 2,
- col = start_col,
- width = width,
- height = ctx_height,
- border = "rounded",
- title = " In Context ",
- title_pos = "left",
- style = "minimal",
- noautocmd = true,
- })
- if not ok then
- pcall(vim.api.nvim_buf_delete, ctx_buf, { force = true })
- ctx_buf = nil
- return
- end
- ctx_win = result
- else
- vim.api.nvim_win_set_config(ctx_win, {
- relative = "editor",
- row = start_row + height + 2,
- col = start_col,
- width = width,
- height = ctx_height,
- })
- end
-
- vim.bo[ctx_buf].modifiable = true
- vim.api.nvim_buf_set_lines(ctx_buf, 0, -1, false, lines)
- vim.bo[ctx_buf].modifiable = false
- end
-
- vim.schedule(refresh_ctx_win)
-
- -- History cycling state
- local hist_index = 0
- local saved_input = ""
-
- local function get_input()
- return vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or ""
- end
-
- local function set_input(text)
- cycling = true
- vim.api.nvim_buf_set_lines(buf, 0, 1, false, { text })
- vim.api.nvim_win_set_cursor(win, { 1, #text })
- cycling = false
- end
-
- local function hist_prev()
- if #history == 0 then return end
- if hist_index == 0 then
- saved_input = get_input()
- end
- hist_index = math.min(hist_index + 1, #history)
- set_input(history[hist_index])
- end
-
- local function hist_next()
- if hist_index == 0 then return end
- hist_index = math.max(hist_index - 1, 0)
- if hist_index == 0 then
- set_input(saved_input)
- else
- set_input(history[hist_index])
- end
- end
-
- -- Close / confirm / cancel
- local function close()
- if closed then return end
- closed = true
- if ctx_win and vim.api.nvim_win_is_valid(ctx_win) then
- vim.api.nvim_win_close(ctx_win, true)
- ctx_win = nil
- ctx_buf = nil
- end
- vim.cmd("stopinsert")
- pcall(vim.api.nvim_del_augroup_by_name, aug_name)
- if vim.api.nvim_win_is_valid(win) then
- vim.api.nvim_win_close(win, true)
- end
- end
-
- local function cancel()
- close()
- vim.schedule(function() on_choice(nil) end)
- end
-
- local function confirm()
- local text = get_input()
- if not text or text == "" then return end
- close()
- vim.schedule(function() on_choice(text) end)
- end
-
- -- Keymaps
- local mo = { buffer = buf, noremap = true, silent = true }
-
- vim.keymap.set("i", "", confirm, mo)
- vim.keymap.set("i", "", cancel, mo)
- vim.keymap.set("i", "", hist_prev, mo)
- vim.keymap.set("i", "", hist_next, mo)
-
- vim.keymap.set("n", "", confirm, mo)
- vim.keymap.set("n", "", cancel, mo)
- vim.keymap.set("n", "q", cancel, mo)
- vim.keymap.set("n", "k", hist_prev, mo)
- vim.keymap.set("n", "j", hist_next, mo)
-
- local function open_context()
- if context_open then return end
- context_open = true
- require("tau.context_picker").open({
- current_file = current_file,
- on_close = function()
- context_open = false
- if not closed and vim.api.nvim_win_is_valid(win) then
- vim.api.nvim_set_current_win(win)
- vim.cmd("startinsert")
- refresh_ctx_win()
- end
- end,
- })
- end
- vim.keymap.set("i", context_key, open_context, mo)
- vim.keymap.set("n", context_key, open_context, mo)
-
- -- Autocmds
- local aug = vim.api.nvim_create_augroup(aug_name, { clear = true })
-
- -- Collapse to single line if user inserts a newline, and reset history index on manual edits
- vim.api.nvim_create_autocmd({ "TextChangedI", "TextChanged" }, {
- buffer = buf,
- group = aug,
- callback = function()
- local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
- if #lines > 1 then
- local input = table.concat(lines, "")
- cycling = true
- vim.api.nvim_buf_set_lines(buf, 0, -1, false, { input })
- vim.api.nvim_win_set_cursor(win, { 1, #input })
- cycling = false
- end
- if not cycling then
- hist_index = 0
- end
- end,
- })
-
- -- Cancel if focus leaves the picker window
- vim.api.nvim_create_autocmd("WinLeave", {
- group = aug,
- callback = function()
- vim.schedule(function()
- if closed then return end
- local cur = vim.api.nvim_get_current_win()
- if cur ~= win and cur ~= ctx_win and not context_open then cancel() end
- end)
- end,
- })
-
- vim.cmd("startinsert")
-end
-
-return M
diff --git a/lua/tau/runner.lua b/lua/tau/runner.lua
deleted file mode 100644
index aa7f406..0000000
--- a/lua/tau/runner.lua
+++ /dev/null
@@ -1,102 +0,0 @@
-local M = {}
-
-local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h:h")
-local bin = plugin_dir .. "/cli/tau"
-
---- Spawn the tau CLI and stream its output.
---- @param opts table
---- - instruction string
---- - selection_text string
---- - context_above string
---- - context_below string
---- - filepath string
---- - filetype string
---- - context_files string[]? absolute paths of session-scoped context files
---- - on_meta fun(meta: table)? called with token estimation metadata
---- - on_token fun(chunk: string)
---- - on_done fun()
---- - on_error fun(msg: string)
---- - config table (from setup())
-function M.run(opts)
- local cfg = opts.config or {}
-
- -- Copy current environment and overlay plugin config
- local env = vim.fn.environ()
- if cfg.api_url then env.TAU_API_URL = cfg.api_url end
- if cfg.api_key then env.TAU_API_KEY = cfg.api_key end
- if cfg.model then env.TAU_MODEL = cfg.model end
- if cfg.debug then env.TAU_DEBUG = "1" end
- if cfg.timeout_ms then env.TAU_TIMEOUT_MS = tostring(cfg.timeout_ms) end
- if cfg.context_window then env.TAU_CONTEXT_WINDOW = tostring(cfg.context_window) end
- if cfg.temperature then env.TAU_TEMPERATURE = tostring(cfg.temperature) end
- if cfg.max_tokens then env.TAU_MAX_TOKENS = tostring(cfg.max_tokens) end
- if cfg.top_p then env.TAU_TOP_P = tostring(cfg.top_p) end
-
- local cmd = { bin, opts.instruction }
-
- if opts.context_above and opts.context_above ~= "" then
- vim.list_extend(cmd, { "--context-above", opts.context_above })
- end
- if opts.context_below and opts.context_below ~= "" then
- vim.list_extend(cmd, { "--context-below", opts.context_below })
- end
- if opts.filepath and opts.filepath ~= "" then
- vim.list_extend(cmd, { "--file", opts.filepath })
- end
- if opts.filetype and opts.filetype ~= "" then
- vim.list_extend(cmd, { "--filetype", opts.filetype })
- end
- if opts.context_files then
- for _, path in ipairs(opts.context_files) do
- vim.list_extend(cmd, { "--context-file", path })
- end
- end
-
- local stderr_buf = ""
-
- local handle = vim.system(cmd, {
- env = env,
- stdin = opts.selection_text,
- stdout = function(_, chunk)
- if chunk then
- opts.on_token(chunk)
- end
- end,
- stderr = function(_, chunk)
- if not chunk then return end
- stderr_buf = stderr_buf .. chunk
- -- Parse and consume complete TAU_META lines eagerly so the UI can show fill % during streaming
- stderr_buf = stderr_buf:gsub("([^\n]*)\n", function(line)
- local json_str = line:match("^TAU_META:(.+)$")
- if json_str and opts.on_meta then
- local ok, meta = pcall(vim.json.decode, json_str)
- if ok then
- vim.schedule(function() opts.on_meta(meta) end)
- end
- end
- return ""
- end)
- end,
- }, function(obj)
- vim.schedule(function()
- -- Strip TAU_META lines from stderr so they don't appear in error messages
- local remaining = {}
- for line in stderr_buf:gmatch("[^\n]+") do
- if not line:match("^TAU_META:") then
- table.insert(remaining, line)
- end
- end
-
- if obj.code == 0 then
- opts.on_done()
- else
- local err_msg = table.concat(remaining, "\n")
- local msg = err_msg ~= "" and err_msg or ("exit code " .. obj.code)
- opts.on_error(msg)
- end
- end)
- end)
- return handle
-end
-
-return M
diff --git a/lua/tau/ui.lua b/lua/tau/ui.lua
deleted file mode 100644
index 939d20a..0000000
--- a/lua/tau/ui.lua
+++ /dev/null
@@ -1,200 +0,0 @@
-local M = {}
-
-local NS = vim.api.nvim_create_namespace("tau")
-local NS_PREVIEW = vim.api.nvim_create_namespace("tau_preview")
-local NS_ERROR = vim.api.nvim_create_namespace("tau_error")
-
-local SPINNER_FRAMES = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }
-local ERROR_CLEAR_TIMEOUT_MS = 5000
-
-local _timer = nil
-local _bufnr = nil
-local _char_count = 0
-local _start_time = 0
-local _meta = nil
-
---- Start spinner above start_line.
---- @param bufnr integer
---- @param start_line integer 1-indexed
-function M.start(bufnr, start_line)
- M.stop()
- vim.api.nvim_buf_clear_namespace(bufnr, NS, 0, -1)
- vim.api.nvim_buf_clear_namespace(bufnr, NS_ERROR, 0, -1)
- _char_count = 0
- _meta = nil
- _start_time = vim.uv.now()
- _bufnr = bufnr
-
- local frame = 1
-
- -- Place initial extmark above the selection's first line
- local mark_id = vim.api.nvim_buf_set_extmark(bufnr, NS, start_line - 1, 0, {
- virt_lines = { { { SPINNER_FRAMES[frame] .. " tau", "Comment" } } },
- virt_lines_above = true,
- })
-
- _timer = vim.uv.new_timer()
- _timer:start(
- 100,
- 100,
- vim.schedule_wrap(function()
- if not _timer then return end
- if not vim.api.nvim_buf_is_valid(bufnr) then
- M.stop()
- return
- end
- frame = (frame % #SPINNER_FRAMES) + 1
- local elapsed = math.floor((vim.uv.now() - _start_time) / 1000)
- local segments = { { SPINNER_FRAMES[frame] .. " tau", "Comment" } }
- if _char_count > 0 then
- table.insert(segments, { " · " .. _char_count .. " chars", "Comment" })
- end
- table.insert(segments, { " · " .. elapsed .. "s", "Comment" })
- if _meta and _meta.fill_pct then
- local pct_str = string.format(" · %.0f%% ctx", _meta.fill_pct)
- local pct_hl = _meta.fill_pct > 90 and "WarningMsg" or "Comment"
- table.insert(segments, { pct_str, pct_hl })
- end
- local pos = vim.api.nvim_buf_get_extmark_by_id(bufnr, NS, mark_id, {})
- local row = (pos and #pos > 0) and pos[1] or (start_line - 1)
- vim.api.nvim_buf_set_extmark(bufnr, NS, row, 0, {
- id = mark_id,
- virt_lines = { segments },
- virt_lines_above = true,
- })
- end)
- )
-end
-
---- Update streaming char count. Called from on_token.
---- @param char_count integer
-function M.update_progress(char_count)
- _char_count = char_count
-end
-
---- Store token estimation metadata for display in the spinner.
---- @param meta table { estimated_tokens: number, context_window: number, fill_pct: number, warning?: string }
-function M.update_meta(meta)
- _meta = meta
-end
-
---- Stop and clear all UI state.
-function M.stop()
- if _timer then
- _timer:stop()
- _timer:close()
- _timer = nil
- end
- _char_count = 0
- _start_time = 0
- _meta = nil
- if _bufnr and vim.api.nvim_buf_is_valid(_bufnr) then
- vim.api.nvim_buf_clear_namespace(_bufnr, NS, 0, -1)
- end
- _bufnr = nil
-end
-
---- Show inline diff: a virtual block above the selection (header + prompt + new lines + separator),
---- with the original buffer lines highlighted as deleted beneath it.
---- @param bufnr integer
---- @param start_line integer 1-indexed
---- @param end_line integer 1-indexed
---- @param new_lines string[]
---- @param instruction string
---- @param meta? table token estimation metadata from CLI
-function M.show_preview(bufnr, start_line, end_line, new_lines, instruction, meta)
- -- Per-line extmarks for original (deleted) lines — skip for empty/whitespace selections
- local orig_lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false)
- local has_content = false
- for _, line in ipairs(orig_lines) do
- if line:match("%S") then has_content = true; break end
- end
-
- if has_content then
- for i = start_line - 1, end_line - 1 do
- vim.api.nvim_buf_set_extmark(bufnr, NS_PREVIEW, i, 0, {
- line_hl_group = "DiffDelete",
- virt_text = { { "-", "DiffDelete" } },
- virt_text_pos = "inline",
- })
- end
- end
-
- -- Resolve the text area width of the window showing this buffer so added
- -- lines can be padded to the same visual width as line_hl_group on removed lines.
- local win = vim.fn.bufwinid(bufnr)
- local win_width = (win ~= -1)
- and (vim.api.nvim_win_get_width(win) - vim.fn.getwininfo(win)[1].textoff)
- or 0
-
- -- Compute width dynamically: "────" needs #title + 4;
- -- each content line needs #("+line"). Uses byte length (correct for ASCII).
- local ctx_suffix = (meta and meta.fill_pct) and string.format(" (%.0f%% ctx)", meta.fill_pct) or ""
- local title = " tau: " .. instruction .. ctx_suffix .. " "
- local w = #title + 4
- for _, line in ipairs(new_lines) do
- w = math.max(w, #line + 1) -- "+line"
- end
- w = math.max(w, win_width)
-
- local sep_top = "──" .. title .. string.rep("─", w - #title - 4) .. "──"
- local sep_mid = string.rep("─", w)
-
- local virt = { { { sep_top, "Comment" } } }
-
- for _, line in ipairs(new_lines) do
- local content = line ~= "" and line or " "
- local padded = content .. string.rep(" ", w - #content - 1)
- table.insert(virt, { { "+", "DiffAdd" }, { padded, "DiffAdd" } })
- end
-
- table.insert(virt, { { sep_mid, "Comment" } })
-
- -- Virtual block (header + proposed lines + separator) above the selection
- vim.api.nvim_buf_set_extmark(bufnr, NS_PREVIEW, start_line - 1, 0, {
- virt_lines = virt,
- virt_lines_above = true,
- })
-
- -- Accept/reject hint as a virtual line below the selection
- vim.api.nvim_buf_set_extmark(bufnr, NS_PREVIEW, end_line - 1, 0, {
- virt_lines = {
- {
- { " ", "Comment" },
- { "", "Special" },
- { " accept · ", "Comment" },
- { "", "Special" },
- { " reject · ", "Comment" },
- { "", "Special" },
- { " regen", "Comment" },
- },
- },
- })
-end
-
---- Clear all preview highlights and virtual lines.
---- @param bufnr integer
-function M.clear_preview(bufnr)
- vim.api.nvim_buf_clear_namespace(bufnr, NS_PREVIEW, 0, -1)
-end
-
---- Show an error as virtual text above the selection and on the cmdline.
---- @param bufnr integer
---- @param start_line integer 1-indexed
---- @param msg string
-function M.error(bufnr, start_line, msg)
- local trimmed = vim.trim(msg):gsub("^tau:%s*", "")
- vim.api.nvim_buf_clear_namespace(bufnr, NS_ERROR, 0, -1)
- local id = vim.api.nvim_buf_set_extmark(bufnr, NS_ERROR, start_line - 1, 0, {
- virt_lines = { { { "✗ tau: " .. trimmed, "ErrorMsg" } } },
- virt_lines_above = true,
- })
- vim.api.nvim_echo({ { "tau: " .. trimmed, "ErrorMsg" } }, false, {})
- vim.defer_fn(function()
- if vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_del_extmark(bufnr, NS_ERROR, id)
- end
- end, ERROR_CLEAR_TIMEOUT_MS)
-end
-
-return M
diff --git a/plugin/patch.lua b/plugin/patch.lua
new file mode 100644
index 0000000..20cb471
--- /dev/null
+++ b/plugin/patch.lua
@@ -0,0 +1,25 @@
+local patch = require("patch")
+
+vim.api.nvim_create_user_command("PatchCapture", patch.start, {
+ desc = "Capture a selection and generate a replacement",
+})
+
+vim.api.nvim_create_user_command("PatchAccept", patch.accept, {
+ desc = "Accept the active replacement proposal",
+})
+
+vim.api.nvim_create_user_command("PatchReject", patch.reject, {
+ desc = "Reject the active replacement proposal",
+})
+
+vim.api.nvim_create_user_command("PatchRetry", patch.retry, {
+ desc = "Retry the active replacement proposal",
+})
+
+vim.api.nvim_create_user_command("PatchCancel", patch.cancel, {
+ desc = "Cancel the active replacement request",
+})
+
+vim.api.nvim_create_user_command("PatchMenu", patch.open_menu, {
+ desc = "List models available to Pi",
+})
diff --git a/plugin/tau.lua b/plugin/tau.lua
deleted file mode 100644
index 051f848..0000000
--- a/plugin/tau.lua
+++ /dev/null
@@ -1,11 +0,0 @@
-vim.api.nvim_create_user_command("Tau", function(opts)
- require("tau").run(opts)
-end, { range = true, nargs = "?" })
-
-vim.api.nvim_create_user_command("TauCancel", function()
- require("tau").cancel()
-end, { desc = "Cancel the in-flight :Tau request" })
-
-vim.api.nvim_create_user_command("TauContext", function()
- require("tau.context_picker").open()
-end, { desc = "Open tau context file picker" })
diff --git a/tests/context_picker_spec.lua b/tests/context_picker_spec.lua
deleted file mode 100644
index b60942a..0000000
--- a/tests/context_picker_spec.lua
+++ /dev/null
@@ -1,125 +0,0 @@
-local function fail(message)
- error(message, 2)
-end
-
-local function assert_equal(actual, expected, message)
- if not vim.deep_equal(actual, expected) then
- fail(string.format("%s\nexpected: %s\nactual: %s", message or "values differ", vim.inspect(expected), vim.inspect(actual)))
- end
-end
-
-local function assert_truthy(value, message)
- if not value then
- fail(message or "expected truthy value")
- end
-end
-
-local function footer_text(win)
- local footer = vim.api.nvim_win_get_config(win).footer
- if type(footer) == "string" then return footer end
- if type(footer) ~= "table" then return "" end
-
- local parts = {}
- for _, item in ipairs(footer) do
- if type(item) == "table" then
- parts[#parts + 1] = tostring(item[1] or "")
- else
- parts[#parts + 1] = tostring(item)
- end
- end
- return table.concat(parts, "")
-end
-
-local function find_map(buf, lhs)
- for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, "n")) do
- if map.lhs == lhs then return map end
- end
- return nil
-end
-
-local function assert_no_line_contains(lines, needle, message)
- for _, line in ipairs(lines) do
- if line:find(needle, 1, true) then
- fail((message or "unexpected line") .. ": " .. line)
- end
- end
-end
-
-local start_dir = vim.loop.cwd()
-local temp_dir = vim.fn.tempname()
-vim.fn.mkdir(temp_dir, "p")
-vim.fn.writefile({ "alpha" }, temp_dir .. "/a.txt")
-vim.fn.writefile({ "bravo" }, temp_dir .. "/b.txt")
-
-local ok, err = xpcall(function()
- vim.cmd("cd " .. vim.fn.fnameescape(temp_dir))
-
- local context_files = require("tau.context_files")
- context_files.clear()
-
- local current_file = vim.fn.fnamemodify("b.txt", ":p")
- require("tau.context_picker").open({ current_file = current_file })
-
- local win = vim.api.nvim_get_current_win()
- local buf = vim.api.nvim_get_current_buf()
- local a_file = vim.fn.fnamemodify("a.txt", ":p")
-
- local space_map = find_map(buf, " ")
- assert_truthy(space_map, "expected buffer-local mapping")
- assert_equal(space_map.nowait, 1, "expected mapping to bypass leader timeout")
- assert_equal(type(space_map.callback), "function", "expected mapping to use a Lua callback")
-
- assert_equal(vim.api.nvim_buf_get_lines(buf, 0, 2, false), {
- " a.txt",
- " ◎ b.txt",
- }, "expected initial picker lines")
-
- vim.api.nvim_win_set_cursor(win, { 1, 0 })
- space_map.callback()
-
- assert_truthy(context_files.contains_abs(a_file), "expected to add selected file to context")
- assert_equal(vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1], " ● a.txt", "expected toggled row to repaint immediately")
- assert_equal(vim.api.nvim_win_get_cursor(win), { 1, 0 }, "expected toggle to preserve cursor row")
-
- local footer_updated = vim.wait(1000, function()
- return footer_text(win):find("1 selected", 1, true) ~= nil
- end, 10)
- assert_truthy(footer_updated, "expected deferred footer update to show selected count")
-
- vim.api.nvim_win_set_cursor(win, { 2, 0 })
- space_map.callback()
-
- assert_truthy(not context_files.contains_abs(current_file), "expected locked current file not to be toggled into context")
- assert_equal(vim.api.nvim_buf_get_lines(buf, 1, 2, false)[1], " ◎ b.txt", "expected locked current-file row to remain unchanged")
-
- local q_map = find_map(buf, "q")
- assert_truthy(q_map, "expected buffer-local q mapping")
- q_map.callback()
-
- local git_dir = vim.fn.tempname()
- vim.fn.mkdir(git_dir .. "/node_modules/pkg", "p")
- vim.fn.writefile({ "node_modules/" }, git_dir .. "/.gitignore")
- vim.fn.writefile({ "visible" }, git_dir .. "/visible.txt")
- vim.fn.writefile({ "ignored" }, git_dir .. "/node_modules/pkg/index.js")
-
- vim.cmd("cd " .. vim.fn.fnameescape(git_dir))
- vim.fn.system({ "git", "init", "-q" })
- assert_equal(vim.v.shell_error, 0, "expected git init to succeed")
-
- require("tau.context_picker").open()
- local git_buf = vim.api.nvim_get_current_buf()
- local lines = vim.api.nvim_buf_get_lines(git_buf, 0, -1, false)
-
- assert_truthy(vim.tbl_contains(lines, " .gitignore"), "expected .gitignore to be listed")
- assert_truthy(vim.tbl_contains(lines, " visible.txt"), "expected visible untracked file to be listed")
- assert_no_line_contains(lines, "node_modules", "expected .gitignore'd node_modules to be excluded")
-
- vim.fn.delete(git_dir, "rf")
-end, debug.traceback)
-
-vim.cmd("cd " .. vim.fn.fnameescape(start_dir))
-vim.fn.delete(temp_dir, "rf")
-
-if not ok then
- error(err, 0)
-end
diff --git a/tests/helpers.lua b/tests/helpers.lua
new file mode 100644
index 0000000..ddfd48c
--- /dev/null
+++ b/tests/helpers.lua
@@ -0,0 +1,36 @@
+local M = {}
+
+function M.new_buffer(lines)
+ local buffer = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_set_current_buf(buffer)
+
+ local undolevels = vim.bo[buffer].undolevels
+ vim.bo[buffer].undolevels = -1
+ vim.api.nvim_buf_set_lines(buffer, 0, -1, false, lines or {})
+ vim.bo[buffer].undolevels = undolevels
+
+ return buffer
+end
+
+function M.set_visual_lines(buffer, start_line, end_line)
+ vim.fn.setpos("'<", { buffer, start_line, 1, 0 })
+ vim.fn.setpos("'>", { buffer, end_line, 1, 0 })
+end
+
+function M.delete_buffer(buffer)
+ if buffer and vim.api.nvim_buf_is_valid(buffer) then
+ vim.api.nvim_buf_delete(buffer, { force = true })
+ end
+end
+
+function M.wait_for(predicate)
+ assert(vim.wait(500, predicate, 5), "timed out waiting for scheduled callback")
+end
+
+function M.unload_modules(names)
+ for _, name in ipairs(names) do
+ package.loaded[name] = nil
+ end
+end
+
+return M
diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua
new file mode 100644
index 0000000..e8a25c1
--- /dev/null
+++ b/tests/minimal_init.lua
@@ -0,0 +1,22 @@
+local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h")
+
+local function dependency_path(environment_name, directory_name)
+ local configured = vim.env[environment_name]
+ if configured and configured ~= "" then
+ return configured
+ end
+
+ local local_dependency = root .. "/.deps/" .. directory_name
+ if vim.fn.isdirectory(local_dependency) == 1 then
+ return local_dependency
+ end
+
+ return vim.fn.stdpath("data") .. "/lazy/" .. directory_name
+end
+
+vim.opt.runtimepath:prepend(root)
+vim.opt.runtimepath:prepend(dependency_path("MINITEST_DIR", "mini.test"))
+require("mini.test").setup({ collect = { emulate_busted = false } })
+
+vim.o.swapfile = false
+vim.o.writebackup = false
diff --git a/tests/patch/test_client.lua b/tests/patch/test_client.lua
new file mode 100644
index 0000000..236222e
--- /dev/null
+++ b/tests/patch/test_client.lua
@@ -0,0 +1,303 @@
+local helpers = require("tests.helpers")
+local eq = MiniTest.expect.equality
+
+local function is_nil(value)
+ eq(value, nil)
+end
+
+local function is_true(value)
+ eq(value, true)
+end
+
+local function is_false(value)
+ eq(value, false)
+end
+
+local client
+local rpc
+local connection
+local handlers
+local start_args
+local sent
+
+local function reset()
+ sent = {}
+ connection = { closed = false }
+ function connection:send(record)
+ table.insert(sent, record)
+ return true
+ end
+ function connection:close()
+ self.closed = true
+ end
+
+ rpc = {
+ start = function(args, supplied_handlers)
+ start_args = args
+ handlers = supplied_handlers
+ return connection
+ end,
+ request = function() end,
+ format_exit_error = function(result)
+ return "exit " .. result.code
+ end,
+ }
+
+ package.loaded["patch.rpc"] = rpc
+ package.loaded["patch.client"] = nil
+ client = require("patch.client")
+ client.setup()
+end
+
+local function cleanup()
+ package.loaded["patch.client"] = nil
+ package.loaded["patch.rpc"] = nil
+end
+
+local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } })
+
+local function test(name, action)
+ T[name] = action
+end
+
+local function wait_for_result(result)
+ helpers.wait_for(function()
+ return result.called
+ end)
+end
+
+local function record(result, values)
+ result.called = true
+ for key, value in pairs(values) do
+ result[key] = value
+ end
+end
+
+local function assistant_message(text, overrides)
+ return vim.tbl_extend("force", {
+ role = "assistant",
+ content = { { type = "text", text = text } },
+ stopReason = "stop",
+ }, overrides or {})
+end
+
+test("sends a prompt and completes only after the agent settles", function()
+ local result = {}
+ local request = client.request("replace this", function(value, err)
+ record(result, { value = value, err = err })
+ end)
+
+ eq(request.state, "pending")
+ eq(sent[1], { type = "prompt", message = "replace this" })
+ eq(start_args, { "--system-prompt", start_args[2], "--no-session", "--thinking", "off" })
+
+ handlers.on_record({ type = "message_end", message = assistant_message("replacement") })
+ vim.wait(20)
+ is_nil(result.called)
+
+ handlers.on_record({ type = "agent_settled" })
+ wait_for_result(result)
+
+ eq(result.value, "replacement")
+ is_nil(result.err)
+ eq(request.state, "completed")
+ is_true(connection.closed)
+end)
+
+test("uses the last assistant message and joins text parts", function()
+ local result = {}
+ client.request("prompt", function(value, err)
+ record(result, { value = value, err = err })
+ end)
+
+ handlers.on_record({ type = "message_end", message = assistant_message("discarded") })
+ handlers.on_record({
+ type = "message_end",
+ message = assistant_message("ignored", {
+ content = {
+ { type = "text", text = "first" },
+ { type = "toolCall", name = "tool" },
+ { type = "text", text = "second" },
+ },
+ }),
+ })
+ handlers.on_record({ type = "agent_settled" })
+ wait_for_result(result)
+
+ eq(result.value, "first\nsecond")
+ is_nil(result.err)
+end)
+
+test("passes configured system prompt and model arguments", function()
+ client.setup({ system_prompt = "custom prompt", model = " openai/model " })
+ client.request("prompt", function() end)
+
+ eq(start_args, {
+ "--system-prompt", "custom prompt", "--no-session", "--thinking", "off",
+ "--model", "openai/model",
+ })
+end)
+
+test("treats empty assistant output as a deletion", function()
+ local result = {}
+ client.request("prompt", function(value, err)
+ record(result, { value = value, err = err })
+ end)
+
+ handlers.on_record({ type = "message_end", message = assistant_message("", { content = {} }) })
+ handlers.on_record({ type = "agent_settled" })
+ wait_for_result(result)
+
+ eq(result.value, "")
+ is_nil(result.err)
+end)
+
+test("cancels a pending request once", function()
+ local result = {}
+ local request = client.request("prompt", function(value, err)
+ record(result, { value = value, err = err })
+ end)
+
+ is_true(client.cancel(request))
+ is_false(client.cancel(request))
+ wait_for_result(result)
+
+ eq(request.state, "cancelled")
+ eq(sent[2], { type = "abort" })
+ is_nil(result.value)
+ eq(result.err, "cancelled")
+
+ handlers.on_record({ type = "agent_settled" })
+ is_true(connection.closed)
+end)
+
+test("reports prompt rejection", function()
+ local result = {}
+ client.request("prompt", function(value, err)
+ record(result, { value = value, err = err })
+ end)
+
+ handlers.on_record({ type = "response", command = "prompt", success = false, error = "denied" })
+ wait_for_result(result)
+
+ is_nil(result.value)
+ eq(result.err, "Pi rejected the prompt: denied")
+ is_true(connection.closed)
+end)
+
+test("reports assistant and missing-response failures", function()
+ local error_result = {}
+ client.request("prompt", function(_, err)
+ record(error_result, { err = err })
+ end)
+ handlers.on_record({
+ type = "message_end",
+ message = assistant_message("", { stopReason = "error", errorMessage = "model failed" }),
+ })
+ handlers.on_record({ type = "agent_settled" })
+ wait_for_result(error_result)
+ eq(error_result.err, "model failed")
+
+ client.setup()
+ connection.closed = false
+ local missing_result = {}
+ client.request("prompt", function(_, err)
+ record(missing_result, { err = err })
+ end)
+ handlers.on_record({ type = "agent_settled" })
+ wait_for_result(missing_result)
+ eq(missing_result.err, "Pi returned no response")
+end)
+
+test("reports process start and exit failures", function()
+ rpc.start = function()
+ return nil, "cannot start"
+ end
+ local start_result = {}
+ client.request("prompt", function(_, err)
+ record(start_result, { err = err })
+ end)
+ wait_for_result(start_result)
+ eq(start_result.err, "cannot start")
+
+ rpc.start = function(args, supplied_handlers)
+ handlers = supplied_handlers
+ return connection
+ end
+ local exit_result = {}
+ client.request("prompt", function(_, err)
+ record(exit_result, { err = err })
+ end)
+ handlers.on_exit({ code = 7 })
+ wait_for_result(exit_result)
+ eq(exit_result.err, "exit 7")
+end)
+
+test("retrieves and validates available models", function()
+ local callback
+ rpc.request = function(command, on_complete)
+ eq(command, { type = "get_available_models" })
+ callback = on_complete
+ end
+
+ local result = {}
+ client.get_available_models(function(models, err)
+ record(result, { models = models, err = err })
+ end)
+ local models = { { provider = "openai", id = "model", name = "Model" } }
+ callback({ data = { models = models } }, nil)
+ wait_for_result(result)
+ eq(result.models, models)
+
+ result = {}
+ client.get_available_models(function(value, err)
+ record(result, { value = value, err = err })
+ end)
+ callback({ data = {} }, nil)
+ wait_for_result(result)
+ is_nil(result.value)
+ eq(result.err, "Pi returned an invalid model list")
+end)
+
+test("resolves configured, session, and cached primary models", function()
+ local result = {}
+ client.setup({ model = "configured/model" })
+ client.resolve_model(function(value, err)
+ record(result, { value = value, err = err })
+ end)
+ wait_for_result(result)
+ eq(result.value, "configured/model")
+
+ eq(client.select_model({ provider = "provider", id = "session" }), "provider/session")
+ result = {}
+ client.resolve_model(function(value)
+ record(result, { value = value })
+ end)
+ wait_for_result(result)
+ eq(result.value, "provider/session")
+
+ local requests = 0
+ local state_callback
+ rpc.request = function(command, on_complete)
+ requests = requests + 1
+ eq(command, { type = "get_state" })
+ state_callback = on_complete
+ end
+ client.setup()
+ result = {}
+ client.resolve_model(function(value, err)
+ record(result, { value = value, err = err })
+ end)
+ state_callback({ data = { model = { provider = "primary", id = "model" } } }, nil)
+ wait_for_result(result)
+ eq(result.value, "primary/model")
+
+ result = {}
+ client.resolve_model(function(value)
+ record(result, { value = value })
+ end)
+ wait_for_result(result)
+ eq(result.value, "primary/model")
+ eq(requests, 1)
+end)
+return T
diff --git a/tests/patch/test_context.lua b/tests/patch/test_context.lua
new file mode 100644
index 0000000..9abdd95
--- /dev/null
+++ b/tests/patch/test_context.lua
@@ -0,0 +1,54 @@
+local helpers = require("tests.helpers")
+local context = require("patch.context")
+local selection = require("patch.selection")
+local eq = MiniTest.expect.equality
+
+local buffer
+local T = MiniTest.new_set({
+ hooks = {
+ post_case = function()
+ helpers.delete_buffer(buffer)
+ end,
+ },
+})
+
+local function capture(start_line, end_line, lines)
+ buffer = helpers.new_buffer(lines)
+ helpers.set_visual_lines(buffer, start_line, end_line)
+ return context.capture(assert(selection.capture()))
+end
+
+T["splits the buffer around the selection"] = function()
+ eq(capture(2, 3, { "one", "two", "three", "four" }), {
+ before = { "one" },
+ selected = { "two", "three" },
+ after = { "four" },
+ })
+end
+
+T["captures a selection at the start of the buffer"] = function()
+ eq(capture(1, 1, { "one", "two" }), {
+ before = {},
+ selected = { "one" },
+ after = { "two" },
+ })
+end
+
+T["captures a selection at the end of the buffer"] = function()
+ eq(capture(2, 2, { "one", "two" }), {
+ before = { "one" },
+ selected = { "two" },
+ after = {},
+ })
+end
+
+T["returns nil for a missing tracked selection"] = function()
+ buffer = helpers.new_buffer({ "one" })
+ helpers.set_visual_lines(buffer, 1, 1)
+ local location = assert(selection.capture())
+ selection.clear(location)
+
+ eq(context.capture(location), nil)
+end
+
+return T
diff --git a/tests/patch/test_init.lua b/tests/patch/test_init.lua
new file mode 100644
index 0000000..df043bb
--- /dev/null
+++ b/tests/patch/test_init.lua
@@ -0,0 +1,285 @@
+local eq = MiniTest.expect.equality
+local patch
+local mocks
+local notifications
+local input
+local requests
+local proposal
+local location
+
+local function reset()
+ notifications = {}
+ requests = {}
+ proposal = { status = "pending" }
+ location = { source_buf = 1, extmark_id = 2 }
+
+ mocks = {
+ prompt = {
+ build = function(_, instruction)
+ return "built:" .. instruction
+ end,
+ },
+ selection = {
+ capture = function()
+ return location
+ end,
+ clear = function(value)
+ mocks.selection.cleared = value
+ end,
+ },
+ context = {
+ capture = function(value)
+ eq(value, location)
+ return { before = {}, selected = { "old" }, after = {} }
+ end,
+ },
+ client = {
+ setup = function(options)
+ mocks.client.setup_options = options
+ end,
+ request = function(message, on_complete)
+ local request = { message = message, on_complete = on_complete, state = "pending" }
+ table.insert(requests, request)
+ return request
+ end,
+ cancel = function(request)
+ request.cancelled = true
+ return true
+ end,
+ resolve_model = function(on_complete)
+ on_complete("provider/current", nil)
+ end,
+ get_available_models = function(on_complete)
+ on_complete({ { provider = "provider", id = "model", name = "Model" } }, nil)
+ end,
+ select_model = function(model)
+ return model.provider .. "/" .. model.id
+ end,
+ },
+ replacement = {
+ apply = function(value, response)
+ mocks.replacement.applied = { value, response }
+ proposal.location = value
+ return proposal
+ end,
+ accept = function(value)
+ mocks.replacement.accepted = value
+ return true
+ end,
+ reject = function(value)
+ mocks.replacement.rejected = value
+ return true
+ end,
+ begin_retry = function(value)
+ mocks.replacement.retry_started = value
+ value.status = "retrying"
+ return true
+ end,
+ complete_retry = function(value, response)
+ mocks.replacement.retry_completed = { value, response }
+ value.status = "pending"
+ return value
+ end,
+ abort_retry = function(value)
+ mocks.replacement.retry_aborted = value
+ value.status = "finished"
+ return true
+ end,
+ },
+ ui = {
+ open_input = function(on_submit, on_close)
+ input = { submit = on_submit, close = on_close }
+ end,
+ open_menu = function(models, selected, on_submit)
+ mocks.ui.menu = { models = models, selected = selected, submit = on_submit }
+ end,
+ },
+ notify = {
+ setup = function(provider)
+ mocks.notify.provider = provider
+ end,
+ send = function(message, level)
+ table.insert(notifications, { message = message, level = level })
+ end,
+ },
+ }
+
+ package.loaded["patch.prompt"] = mocks.prompt
+ package.loaded["patch.selection"] = mocks.selection
+ package.loaded["patch.context"] = mocks.context
+ package.loaded["patch.client"] = mocks.client
+ package.loaded["patch.replacement"] = mocks.replacement
+ package.loaded["patch.ui"] = mocks.ui
+ package.loaded["patch.notify"] = mocks.notify
+ package.loaded["patch"] = nil
+ patch = require("patch")
+end
+
+local function cleanup()
+ for _, name in ipairs({
+ "patch", "patch.prompt", "patch.selection", "patch.context", "patch.client",
+ "patch.replacement", "patch.ui", "patch.notify",
+ }) do
+ package.loaded[name] = nil
+end
+end
+
+local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } })
+
+local function test(name, action)
+ T[name] = action
+end
+
+local function notification(message, level)
+ for _, item in ipairs(notifications) do
+ if item.message == message and (not level or item.level == level) then
+ return item
+ end
+ end
+end
+
+local function start_and_submit()
+ patch.start()
+ eq(type(input), "table")
+ input.submit("change it")
+ eq(#requests, 1)
+end
+
+local function complete_generation(response, err)
+ requests[#requests].on_complete(response, err)
+end
+
+test("passes configuration to notification and client modules", function()
+ local provider = function() end
+ patch.setup({ notify = provider, system_prompt = "system", model = "provider/model" })
+
+ eq(mocks.notify.provider, provider)
+ eq(mocks.client.setup_options, { system_prompt = "system", model = "provider/model" })
+end)
+
+test("runs capture, generation, review, and acceptance", function()
+ start_and_submit()
+
+ eq(requests[1].message, "built:change it")
+ assert(notification("patch: generating...", vim.log.levels.INFO))
+
+ complete_generation("replacement", nil)
+ eq(mocks.replacement.applied, { location, "replacement" })
+ assert(notification("patch: complete", vim.log.levels.INFO))
+
+ patch.accept()
+ eq(mocks.replacement.accepted, proposal)
+
+ patch.accept()
+ assert(notification("patch: no active proposal to accept", vim.log.levels.WARN))
+end)
+
+test("rejects a reviewed proposal", function()
+ start_and_submit()
+ complete_generation("replacement", nil)
+
+ patch.reject()
+
+ eq(mocks.replacement.rejected, proposal)
+ patch.reject()
+ assert(notification("patch: no active proposal to reject", vim.log.levels.WARN))
+end)
+
+test("retries a proposal and applies the new response", function()
+ start_and_submit()
+ complete_generation("first", nil)
+
+ patch.retry()
+ eq(mocks.replacement.retry_started, proposal)
+ eq(#requests, 2)
+
+ complete_generation("second", nil)
+ eq(mocks.replacement.retry_completed, { proposal, "second" })
+ assert(notification("patch: complete", vim.log.levels.INFO))
+end)
+
+test("aborts a failed retry and reports the error", function()
+ start_and_submit()
+ complete_generation("first", nil)
+ patch.retry()
+
+ complete_generation(nil, "request failed")
+
+ eq(mocks.replacement.retry_aborted, proposal)
+ assert(notification("patch: request failed", vim.log.levels.ERROR))
+end)
+
+test("cancels an active request and suppresses the cancellation error", function()
+ start_and_submit()
+
+ patch.cancel()
+ eq(requests[1].cancelled, true)
+ assert(notification("patch: cancelled", vim.log.levels.INFO))
+
+ complete_generation(nil, "cancelled")
+ eq(mocks.selection.cleared, location)
+ eq(notification("patch: cancelled", vim.log.levels.ERROR), nil)
+end)
+
+test("cleans up when input closes or generation fails", function()
+ patch.start()
+ input.close()
+ eq(mocks.selection.cleared, location)
+
+ mocks.selection.cleared = nil
+ start_and_submit()
+ complete_generation(nil, "failed")
+ eq(mocks.selection.cleared, location)
+ assert(notification("patch: failed", vim.log.levels.ERROR))
+end)
+
+test("blocks overlapping workflows", function()
+ patch.start()
+ patch.start()
+ assert(notification("patch: submit or close the active instruction first", vim.log.levels.WARN))
+
+ input.submit("change")
+ patch.start()
+ assert(notification("patch: a replacement is already being generated", vim.log.levels.WARN))
+
+ complete_generation("replacement", nil)
+ patch.start()
+ assert(notification("patch: accept or reject the active proposal first", vim.log.levels.WARN))
+end)
+
+test("ignores stale completion callbacks", function()
+ start_and_submit()
+ local stale = requests[1].on_complete
+ patch.cancel()
+ stale(nil, "cancelled")
+
+ patch.start()
+ input.submit("new change")
+ stale("stale response", nil)
+
+ eq(mocks.replacement.applied, nil)
+ eq(#requests, 2)
+end)
+
+test("opens the model menu and records a selection", function()
+ patch.open_menu()
+
+ eq(mocks.ui.menu.selected, "provider/current")
+ eq(mocks.ui.menu.models[1].id, "model")
+ mocks.ui.menu.submit(mocks.ui.menu.models[1])
+
+ assert(notification("patch: using provider/model", vim.log.levels.INFO))
+end)
+
+test("warns when actions have no active workflow", function()
+ patch.accept()
+ patch.reject()
+ patch.retry()
+ patch.cancel()
+
+ assert(notification("patch: no active proposal to accept", vim.log.levels.WARN))
+ assert(notification("patch: no active proposal to reject", vim.log.levels.WARN))
+ assert(notification("patch: no active proposal to retry", vim.log.levels.WARN))
+ assert(notification("patch: nothing to cancel", vim.log.levels.WARN))
+end)
+return T
diff --git a/tests/patch/test_notify.lua b/tests/patch/test_notify.lua
new file mode 100644
index 0000000..d1717fb
--- /dev/null
+++ b/tests/patch/test_notify.lua
@@ -0,0 +1,45 @@
+local eq = MiniTest.expect.equality
+local notify
+local original_notify
+
+local T = MiniTest.new_set({
+ hooks = {
+ pre_case = function()
+ package.loaded["patch.notify"] = nil
+ notify = require("patch.notify")
+ original_notify = vim.notify
+ end,
+ post_case = function()
+ vim.notify = original_notify
+ notify.setup(nil)
+ end,
+ },
+})
+
+T["uses vim.notify with the default title"] = function()
+ local received
+ vim.notify = function(message, level, options)
+ received = { message, level, options }
+ end
+
+ notify.send("message", vim.log.levels.INFO)
+
+ eq(received[1], "message")
+ eq(received[2], vim.log.levels.INFO)
+ eq(received[3], { title = "patch.nvim" })
+end
+
+T["uses a configured callable provider and preserves options"] = function()
+ local received
+ notify.setup(function(message, level, options)
+ received = { message, level, options }
+ end)
+
+ notify.send("warning", vim.log.levels.WARN, { title = "custom", timeout = 10 })
+
+ eq(received[1], "warning")
+ eq(received[2], vim.log.levels.WARN)
+ eq(received[3], { title = "custom", timeout = 10 })
+end
+
+return T
diff --git a/tests/patch/test_plugin.lua b/tests/patch/test_plugin.lua
new file mode 100644
index 0000000..a466843
--- /dev/null
+++ b/tests/patch/test_plugin.lua
@@ -0,0 +1,63 @@
+local eq = MiniTest.expect.equality
+local calls
+local command_names = {
+ "PatchCapture", "PatchAccept", "PatchReject", "PatchRetry", "PatchCancel", "PatchMenu",
+}
+
+local function clear_commands()
+ for _, name in ipairs(command_names) do
+ pcall(vim.api.nvim_del_user_command, name)
+ end
+end
+
+local T = MiniTest.new_set({
+ hooks = {
+ pre_case = function()
+ clear_commands()
+ calls = {}
+ package.loaded["patch"] = {
+ start = function() calls.start = true end,
+ accept = function() calls.accept = true end,
+ reject = function() calls.reject = true end,
+ retry = function() calls.retry = true end,
+ cancel = function() calls.cancel = true end,
+ open_menu = function() calls.open_menu = true end,
+ }
+
+ dofile(vim.fn.getcwd() .. "/plugin/patch.lua")
+ end,
+ post_case = function()
+ clear_commands()
+ package.loaded["patch"] = nil
+ end,
+ },
+})
+
+T["registers the documented user commands"] = function()
+ local commands = vim.api.nvim_get_commands({})
+
+ for _, name in ipairs(command_names) do
+ eq(type(commands[name]), "table")
+ eq(type(commands[name].definition), "string")
+ end
+end
+
+T["routes commands to the plugin entrypoints"] = function()
+ vim.cmd.PatchCapture()
+ vim.cmd.PatchAccept()
+ vim.cmd.PatchReject()
+ vim.cmd.PatchRetry()
+ vim.cmd.PatchCancel()
+ vim.cmd.PatchMenu()
+
+ eq(calls, {
+ start = true,
+ accept = true,
+ reject = true,
+ retry = true,
+ cancel = true,
+ open_menu = true,
+ })
+end
+
+return T
diff --git a/tests/patch/test_prompt.lua b/tests/patch/test_prompt.lua
new file mode 100644
index 0000000..25840fc
--- /dev/null
+++ b/tests/patch/test_prompt.lua
@@ -0,0 +1,46 @@
+local prompt = require("patch.prompt")
+local eq = MiniTest.expect.equality
+
+local T = MiniTest.new_set()
+
+T["formats captured context in order"] = function()
+ local result = prompt.format_context({
+ before = { "local before = true", "" },
+ selected = { "old()" },
+ after = { "return before" },
+ })
+
+ eq(result, table.concat({
+ "--- BEFORE ---",
+ "local before = true",
+ "",
+ "--- SELECTED ---",
+ "old()",
+ "--- AFTER ---",
+ "return before",
+ }, "\n"))
+end
+
+T["marks empty context groups"] = function()
+ eq(prompt.format_context({ before = {}, selected = {}, after = {} }), table.concat({
+ "--- BEFORE ---",
+ "(empty)",
+ "--- SELECTED ---",
+ "(empty)",
+ "--- AFTER ---",
+ "(empty)",
+ }, "\n"))
+end
+
+T["appends a multiline instruction"] = function()
+ local message = prompt.build({
+ before = {},
+ selected = { "value" },
+ after = {},
+ }, "Rename this.\nKeep its type.")
+
+ assert(message:find("--- SELECTED ---\nvalue", 1, true))
+ assert(message:find("--- INSTRUCTION ---\nRename this.\nKeep its type.", 1, true))
+end
+
+return T
diff --git a/tests/patch/test_replacement.lua b/tests/patch/test_replacement.lua
new file mode 100644
index 0000000..2bfd334
--- /dev/null
+++ b/tests/patch/test_replacement.lua
@@ -0,0 +1,116 @@
+local helpers = require("tests.helpers")
+local replacement = require("patch.replacement")
+local selection = require("patch.selection")
+local eq = MiniTest.expect.equality
+
+local buffer
+local location
+local T = MiniTest.new_set({
+ hooks = {
+ pre_case = function()
+ buffer = helpers.new_buffer({ "before", "old one", "old two", "after" })
+ helpers.set_visual_lines(buffer, 2, 3)
+ location = assert(selection.capture())
+ end,
+ post_case = function()
+ helpers.delete_buffer(buffer)
+ end,
+ },
+})
+
+local function lines()
+ return vim.api.nvim_buf_get_lines(buffer, 0, -1, false)
+end
+
+T["previews generated lines without changing buffer text"] = function()
+ local proposal = assert(replacement.apply(location, "new one\nnew two\n"))
+
+ eq(lines(), { "before", "old one", "old two", "after" })
+ eq(proposal.generated_lines, { "new one", "new two" })
+ eq(proposal.status, "pending")
+ eq(type(proposal.generated_mark), "number")
+end
+
+T["removes a CRLF terminal newline"] = function()
+ local proposal = assert(replacement.apply(location, "new one\r\n"))
+
+ eq(proposal.generated_lines, { "new one" })
+end
+
+T["accepts a proposal and replaces the original lines"] = function()
+ local proposal = assert(replacement.apply(location, "new one\nnew two"))
+
+ eq(replacement.accept(proposal), true)
+ eq(lines(), { "before", "new one", "new two", "after" })
+ eq(proposal.status, "finished")
+ eq(selection.resolve(location), nil)
+end
+
+T["supports an empty response as a deletion"] = function()
+ local proposal = assert(replacement.apply(location, ""))
+
+ eq(proposal.generated_lines, {})
+ eq(replacement.accept(proposal), true)
+ eq(lines(), { "before", "after" })
+end
+
+T["leaves the buffer unchanged when rejected"] = function()
+ local proposal = assert(replacement.apply(location, "replacement"))
+
+ eq(replacement.reject(proposal), true)
+ eq(lines(), { "before", "old one", "old two", "after" })
+ eq(proposal.status, "finished")
+end
+
+T["undoes an accepted proposal as one buffer change"] = function()
+ local proposal = assert(replacement.apply(location, "replacement"))
+ eq(replacement.accept(proposal), true)
+
+ vim.cmd.undo()
+
+ eq(lines(), { "before", "old one", "old two", "after" })
+end
+
+T["hides the old preview and completes a retry"] = function()
+ local proposal = assert(replacement.apply(location, "first"))
+
+ eq(replacement.begin_retry(proposal), true)
+ eq(proposal.status, "retrying")
+ eq(proposal.generated_lines, {})
+ eq(proposal.generated_mark, nil)
+
+ eq(replacement.complete_retry(proposal, "second\nthird"), proposal)
+ eq(proposal.status, "pending")
+ eq(proposal.generated_lines, { "second", "third" })
+end
+
+T["finalises an aborted retry"] = function()
+ local proposal = assert(replacement.apply(location, "first"))
+ eq(replacement.begin_retry(proposal), true)
+
+ eq(replacement.abort_retry(proposal), true)
+ eq(proposal.status, "finished")
+ eq(selection.resolve(location), nil)
+end
+
+T["rejects lifecycle operations in the wrong state"] = function()
+ local proposal = assert(replacement.apply(location, "first"))
+ eq(replacement.reject(proposal), true)
+
+ eq(replacement.accept(proposal), false)
+ eq(replacement.reject(proposal), false)
+ eq(replacement.begin_retry(proposal), false)
+ eq(replacement.abort_retry(proposal), false)
+ eq(replacement.complete_retry(proposal, "second"), nil)
+end
+
+T["fails safely after the source buffer is unloaded"] = function()
+ local proposal = assert(replacement.apply(location, "replacement"))
+ vim.api.nvim_buf_delete(buffer, { force = true })
+ buffer = nil
+
+ eq(replacement.accept(proposal), false)
+ eq(proposal.status, "finished")
+end
+
+return T
diff --git a/tests/patch/test_rpc.lua b/tests/patch/test_rpc.lua
new file mode 100644
index 0000000..40e1dac
--- /dev/null
+++ b/tests/patch/test_rpc.lua
@@ -0,0 +1,262 @@
+local eq = MiniTest.expect.equality
+local rpc
+local original_system
+
+local function reset()
+ package.loaded["patch.rpc"] = nil
+ rpc = require("patch.rpc")
+ original_system = vim.system
+end
+
+local function cleanup()
+ vim.system = original_system
+end
+
+local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } })
+
+local function test(name, action)
+ T[name] = action
+end
+
+local function fake_system()
+ local invocation = {}
+ local process = { writes = {} }
+
+ function process:write(value)
+ table.insert(self.writes, value == nil and vim.NIL or value)
+ end
+
+ vim.system = function(command, options, on_exit)
+ invocation.command = command
+ invocation.options = options
+ invocation.on_exit = on_exit
+ invocation.process = process
+ return process
+ end
+
+ return invocation
+end
+
+test("starts Pi in RPC mode and sends JSONL records", function()
+ local invocation = fake_system()
+ local connection = assert(rpc.start({ "--no-session" }, {
+ on_record = function() end,
+ on_error = function() end,
+ on_exit = function() end,
+ }))
+
+ eq(invocation.command, {
+ "pi",
+ "--mode", "rpc",
+ "--no-context-files",
+ "--no-skills",
+ "--no-extensions",
+ "--no-prompt-templates",
+ "--no-themes",
+ "--no-tools",
+ "--no-approve",
+ "--append-system-prompt", "",
+ "--no-session",
+ })
+ eq(invocation.options.stdin, true)
+ eq(type(invocation.options.stdout), "function")
+ eq(connection:send({ type = "prompt", message = "hello" }), true)
+
+ local sent = vim.json.decode(invocation.process.writes[1])
+ eq(sent, { type = "prompt", message = "hello" })
+end)
+
+test("decodes records split across stdout chunks", function()
+ local invocation = fake_system()
+ local records = {}
+ assert(rpc.start({}, {
+ on_record = function(record)
+ table.insert(records, record)
+ end,
+ on_error = function() end,
+ on_exit = function() end,
+ }))
+
+ invocation.options.stdout(nil, '{"type":"fir')
+ invocation.options.stdout(nil, 'st"}\n{"type":"second"}\n')
+
+ eq(records, { { type = "first" }, { type = "second" } })
+end)
+
+test("decodes a final record without a newline", function()
+ local invocation = fake_system()
+ local records = {}
+ assert(rpc.start({}, {
+ on_record = function(record)
+ table.insert(records, record)
+ end,
+ on_error = function() end,
+ on_exit = function() end,
+ }))
+
+ invocation.options.stdout(nil, '{"type":"final"}')
+ invocation.options.stdout(nil, nil)
+
+ eq(records, { { type = "final" } })
+end)
+
+test("reports malformed JSON once and closes the connection", function()
+ local invocation = fake_system()
+ local errors = {}
+ local connection = assert(rpc.start({}, {
+ on_record = function() end,
+ on_error = function(err)
+ table.insert(errors, err)
+ end,
+ on_exit = function() end,
+ }))
+
+ invocation.options.stdout(nil, "not-json\n")
+ invocation.options.stdout(nil, "still-not-json\n")
+
+ eq(#errors, 1)
+ assert(errors[1]:find("Failed to decode Pi output:", 1, true))
+ eq(connection.closed, true)
+end)
+
+test("reports stdout errors", function()
+ local invocation = fake_system()
+ local received
+ assert(rpc.start({}, {
+ on_record = function() end,
+ on_error = function(err)
+ received = err
+ end,
+ on_exit = function() end,
+ }))
+
+ invocation.options.stdout("broken pipe", nil)
+
+ eq(received, "Failed to read Pi output: broken pipe")
+end)
+
+test("closes idempotently and rejects later writes", function()
+ local invocation = fake_system()
+ local connection = assert(rpc.start({}, {
+ on_record = function() end,
+ on_error = function() end,
+ on_exit = function() end,
+ }))
+
+ connection:close()
+ connection:close()
+ local sent, err = connection:send({ type = "prompt" })
+
+ eq(sent, false)
+ eq(err, "Pi connection is closed")
+ eq(#invocation.process.writes, 1)
+ eq(invocation.process.writes[1], vim.NIL)
+end)
+
+test("reports process start failures", function()
+ vim.system = function()
+ error("not executable")
+ end
+
+ local connection, err = rpc.start({}, {
+ on_record = function() end,
+ on_error = function() end,
+ on_exit = function() end,
+ })
+
+ eq(connection, nil)
+ assert(err:find("Failed to start Pi:", 1, true))
+ assert(err:find("not executable", 1, true))
+end)
+
+test("formats process errors with trimmed stderr", function()
+ eq(rpc.format_exit_error({ code = 3, stderr = " failure\n" }), "Pi exited with code 3: failure")
+ eq(rpc.format_exit_error({ code = 2, stderr = "" }), "Pi exited with code 2")
+end)
+
+local handlers
+local connection
+local sent
+
+local function reset_one_shot()
+ connection = { closed = false }
+ function connection:send(record)
+ sent = record
+ return true
+ end
+ function connection:close()
+ self.closed = true
+ end
+
+ rpc.start = function(args, supplied_handlers)
+ eq(args, { "--no-session" })
+ handlers = supplied_handlers
+ return connection
+ end
+end
+
+local one_shot = MiniTest.new_set({ hooks = { pre_case = reset_one_shot } })
+T["one-shot requests"] = one_shot
+local function one_shot_test(name, action)
+ one_shot[name] = action
+end
+
+one_shot_test("matches the command and request id", function()
+ local result
+ rpc.request({ type = "get_state" }, function(record, err)
+ result = { record, err }
+ end)
+
+ eq(sent, { type = "get_state", id = "get_state" })
+ handlers.on_record({ type = "event" })
+ eq(result, nil)
+ handlers.on_record({ type = "response", command = "get_state", id = "other", success = true })
+ eq(result, nil)
+
+ local record = { type = "response", command = "get_state", id = "get_state", success = true }
+ handlers.on_record(record)
+ eq(result[1], record)
+ eq(result[2], nil)
+ eq(connection.closed, true)
+end)
+
+one_shot_test("reports a rejected command", function()
+ local received
+ rpc.request({ type = "get_state" }, function(_, err)
+ received = err
+ end)
+
+ handlers.on_record({
+ type = "response",
+ command = "get_state",
+ id = "get_state",
+ success = false,
+ error = "denied",
+ })
+
+ eq(received, "Pi rejected get_state: denied")
+end)
+
+one_shot_test("reports an early process exit", function()
+ local received
+ rpc.request({ type = "get_state" }, function(_, err)
+ received = err
+ end)
+
+ handlers.on_exit({ code = 0, stderr = "" })
+
+ eq(received, "Pi exited before returning get_state")
+end)
+
+one_shot_test("completes only once", function()
+ local completions = 0
+ rpc.request({ type = "get_state" }, function()
+ completions = completions + 1
+ end)
+
+ handlers.on_error("failed")
+ handlers.on_exit({ code = 1, stderr = "later" })
+
+ eq(completions, 1)
+end)
+return T
diff --git a/tests/patch/test_selection.lua b/tests/patch/test_selection.lua
new file mode 100644
index 0000000..803ae04
--- /dev/null
+++ b/tests/patch/test_selection.lua
@@ -0,0 +1,80 @@
+local helpers = require("tests.helpers")
+local selection = require("patch.selection")
+local eq = MiniTest.expect.equality
+
+local buffer
+local T = MiniTest.new_set({
+ hooks = {
+ pre_case = function()
+ buffer = helpers.new_buffer({ "one", "two", "three", "four" })
+ end,
+ post_case = function()
+ helpers.delete_buffer(buffer)
+ end,
+ },
+})
+
+T["captures and resolves a visual-line selection"] = function()
+ helpers.set_visual_lines(buffer, 2, 3)
+
+ local location = assert(selection.capture())
+ local range = assert(selection.resolve(location))
+
+ eq(range, { source_buf = buffer, start_row = 1, end_row = 3 })
+end
+
+T["normalises reversed visual marks"] = function()
+ helpers.set_visual_lines(buffer, 4, 2)
+
+ local range = assert(selection.resolve(assert(selection.capture())))
+
+ eq(range, { source_buf = buffer, start_row = 1, end_row = 4 })
+end
+
+T["returns nil when no visual selection has been recorded"] = function()
+ vim.fn.setpos("'<", { buffer, 0, 0, 0 })
+ vim.fn.setpos("'>", { buffer, 0, 0, 0 })
+
+ eq(selection.capture(), nil)
+end
+
+T["tracks the selected lines when text is inserted before them"] = function()
+ helpers.set_visual_lines(buffer, 2, 3)
+ local location = assert(selection.capture())
+
+ vim.api.nvim_buf_set_lines(buffer, 0, 0, false, { "zero" })
+
+ eq(selection.resolve(location), { source_buf = buffer, start_row = 2, end_row = 4 })
+end
+
+T["decorates without changing the tracked range"] = function()
+ helpers.set_visual_lines(buffer, 2, 3)
+ local location = assert(selection.capture())
+ local original = selection.resolve(location)
+
+ eq(selection.decorate(location, { hl_group = "DiffDelete", hl_eol = true }), true)
+ eq(selection.resolve(location), original)
+end
+
+T["clears the tracked selection"] = function()
+ helpers.set_visual_lines(buffer, 2, 3)
+ local location = assert(selection.capture())
+
+ selection.clear(location)
+
+ eq(selection.resolve(location), nil)
+end
+
+T["does not resolve a selection in an unloaded buffer"] = function()
+ helpers.set_visual_lines(buffer, 2, 3)
+ local location = assert(selection.capture())
+ vim.api.nvim_buf_delete(buffer, { force = true })
+ buffer = nil
+
+ eq(selection.resolve(location), nil)
+ MiniTest.expect.no_error(function()
+ selection.clear(location)
+ end)
+end
+
+return T