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. -image +## 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" }, - { "<CR>", "Special" }, - { " accept · ", "Comment" }, - { "<Esc>", "Special" }, - { " reject · ", "Comment" }, - { "<r>", "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 <Space> mapping") - assert_equal(space_map.nowait, 1, "expected <Space> mapping to bypass leader timeout") - assert_equal(type(space_map.callback), "function", "expected <Space> 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 <Space> 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