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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@

# Test dependencies (plenary.nvim, installed by tests/run_lua.sh)
deps/

# Test output captured during local test runs
test_output.log
67 changes: 58 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A Neovim plugin that shows a **diff preview before your AI coding agent applies any file change** — letting you review exactly what's changing before accepting.

Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), and [GitHub Copilot CLI](https://github.com/github/copilot-cli) as backends.
Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [GitHub Copilot CLI](https://github.com/github/copilot-cli), and [OpenAI Codex CLI](https://github.com/openai/codex) as backends.

---

Expand All @@ -17,6 +17,9 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod
### GitHub Copilot CLI
![GitHub Copilot CLI demo](docs/code-preview-copilot.gif)

### OpenAI Codex CLI
![OpenAI Codex CLI demo](docs/code-preview-codex.gif)

---

## Table of Contents
Expand All @@ -28,6 +31,7 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod
- [Claude Code](#claude-code)
- [OpenCode](#opencode)
- [GitHub Copilot CLI](#github-copilot-cli)
- [OpenAI Codex CLI](#openai-codex-cli)
- [How it works](#how-it-works)
- [Configuration](#configuration)
- [Commands](#commands)
Expand All @@ -44,14 +48,14 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod
- **Diff preview** — side-by-side or inline diff opens in Neovim before any file is written
- **Multiple layouts** — tab, vsplit, or GitHub-style inline diff with syntax highlighting
- **Neo-tree integration** — file tree indicators show which files are being modified, created, or deleted
- **Multi-backend** — works with Claude Code CLI and OpenCode
- **Multi-backend** — works with Claude Code, OpenCode, GitHub Copilot CLI, and OpenAI Codex CLI
- **No Python dependency** — file transformations use `nvim --headless -l`

---

## Requirements

- Neovim >= 0.9
- Neovim >= 0.10

**For Claude Code backend:**
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) with hooks support
Expand All @@ -62,6 +66,9 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod

**For GitHub Copilot CLI backend:**
- [GitHub Copilot CLI](https://github.com/github/copilot-cli) (generally available since Feb 2026)

**For OpenAI Codex CLI backend:**
- [OpenAI Codex CLI](https://github.com/openai/codex) (recent enough to support `apply_patch` PreToolUse hooks; older builds only fired hooks for `Bash`)
- [jq](https://jqlang.github.io/jq/) — for hook payload translation

---
Expand Down Expand Up @@ -131,6 +138,31 @@ require("code-preview").setup()

> **Note:** Copilot CLI does not fire post-tool hooks on rejection, so rejected diffs remain open until you dismiss them (same as Claude Code).

### OpenAI Codex CLI

1. Install the plugin and call `setup()`
2. Open a project in Neovim
3. Run `:CodePreviewInstallCodexCliHooks` — writes `.codex/hooks.json`
4. Codex requires a feature flag to enable hooks, and the diff-preview workflow only makes sense when Codex asks before applying edits. Create or edit `.codex/config.toml` (project-local) or `~/.codex/config.toml` (global) and add:

```toml
approval_policy = "on-request"
sandbox_mode = "read-only"

[features]
codex_hooks = true
```

`approval_policy = "on-request"` and `sandbox_mode = "read-only"` ensure Codex prompts you before every edit, so the diff preview has time to open and you have time to review. Without them, Codex may apply changes without prompting and the preview window will never block on your decision.

The installer warns you if `codex_hooks` is missing. You can re-check at any time with `:CodePreviewStatus` or `:checkhealth code-preview`, which both report whether the feature flag is detected.
5. Start Codex CLI in the project directory
6. Ask Codex to edit a file — a diff opens automatically in Neovim
7. Accept/reject in the CLI; the diff closes automatically on accept
8. If rejected, press `<leader>dq` to close the diff manually

> **Note:** Today's Codex models route all file edits through the `apply_patch` tool. New file creation that Codex performs via shell redirection (e.g. `printf … > foo.txt`) is not previewed — only `apply_patch` and edits via the dedicated `Edit`/`Write` tools (when emitted) are.

---

## How it works
Expand All @@ -155,6 +187,8 @@ AI Agent (terminal) Neovim

**GitHub Copilot CLI** uses shell-based hooks (`preToolUse`/`postToolUse`) configured in `.github/hooks/code-preview.json`. The adapter translates Copilot's tool vocabulary (`apply_patch`, `edit`, `create`, `bash`) into the same normalized format used by the other backends.

**OpenAI Codex CLI** uses shell-based hooks (`PreToolUse`/`PostToolUse`) configured in `.codex/hooks.json`, gated by `codex_hooks = true` under `[features]` in `.codex/config.toml`. The adapter passes `Bash` through and rewrites `apply_patch` (whose patch text lives in `tool_input.command`) into the canonical `ApplyPatch` shape with `tool_input.patch_text`.

All backends communicate with Neovim via RPC (`nvim --server <socket> --remote-send`).

---
Expand Down Expand Up @@ -209,10 +243,12 @@ require("code-preview").setup({
| `:CodePreviewUninstallOpenCodeHooks` | Remove OpenCode plugin |
| `:CodePreviewInstallCopilotCliHooks` | Install Copilot CLI hooks to `.github/hooks/code-preview.json` |
| `:CodePreviewUninstallCopilotCliHooks` | Remove Copilot CLI hooks |
| `:CodePreviewInstallCodexCliHooks` | Install Codex CLI hooks to `.codex/hooks.json` |
| `:CodePreviewUninstallCodexCliHooks` | Remove Codex CLI hooks |
| `:CodePreviewCloseDiff` | Manually close the diff (use after rejecting a change) |
| `:CodePreviewStatus` | Show socket path, hook status, and dependency check |
| `:CodePreviewToggleVisibleOnly` | Toggle visible_only — show diffs only for open buffers |
| `:checkhealth code-preview` | Full health check (both backends) |
| `:checkhealth code-preview` | Full health check (all backends) |

> **Migrating?** The old `:ClaudePreview*` commands still work but show a deprecation warning. They will be removed in a future release.

Expand Down Expand Up @@ -330,10 +366,12 @@ code-preview.nvim/
│ ├── log.lua opt-in debug logging
│ ├── changes.lua change status registry (modified/created/deleted)
│ ├── neo_tree.lua neo-tree integration (icons, virtual nodes, reveal)
│ ├── health.lua :checkhealth (both backends)
│ ├── health.lua :checkhealth (all backends)
│ └── backends/
│ ├── claudecode.lua Claude Code hook install/uninstall
│ └── opencode.lua OpenCode plugin install/uninstall
│ ├── opencode.lua OpenCode plugin install/uninstall
│ ├── copilot.lua GitHub Copilot CLI hook install/uninstall
│ └── codex.lua OpenAI Codex CLI hook install/uninstall
├── bin/ Shared core scripts
│ ├── core-pre-tool.sh Unified PreToolUse logic
│ ├── core-post-tool.sh Unified PostToolUse logic
Expand All @@ -350,9 +388,12 @@ code-preview.nvim/
│ │ ├── index.ts tool.execute.before/after hooks
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── copilot/ GitHub Copilot CLI adapter
│ ├── code-preview-diff.sh preToolUse hook — translates Copilot JSON → core
│ └── code-close-diff.sh postToolUse hook — same for close
│ ├── copilot/ GitHub Copilot CLI adapter
│ │ ├── code-preview-diff.sh preToolUse hook — translates Copilot JSON → core
│ │ └── code-close-diff.sh postToolUse hook — same for close
│ └── codex/ OpenAI Codex CLI adapter
│ ├── code-preview-diff.sh PreToolUse hook — translates Codex JSON → core
│ └── code-close-diff.sh PostToolUse hook — same for close
```

---
Expand All @@ -367,6 +408,8 @@ The test suite uses [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) for
./tests/run.sh backends # all backend integration tests
./tests/run.sh backends/claudecode # Claude Code backend only
./tests/run.sh backends/opencode # OpenCode backend only
./tests/run.sh backends/copilot # GitHub Copilot CLI backend only
./tests/run.sh backends/codex # OpenAI Codex CLI backend only
```

**Dependencies:** Neovim >= 0.10, jq, bun (for OpenCode tests). Plenary is auto-installed to `deps/` on first run.
Expand Down Expand Up @@ -405,6 +448,12 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, {
- Ensure `"permission": { "edit": "ask" }` is set in `~/.config/opencode/opencode.json`
- Restart OpenCode

**Codex CLI hooks not firing**
- Run `:CodePreviewInstallCodexCliHooks` in the project root
- Confirm `.codex/config.toml` contains `[features]` with `codex_hooks = true` (without it, Codex ignores `hooks.json` silently)
- Update Codex if needed — older versions only fired hooks for `Bash`, not `apply_patch`
- Run `:CodePreviewStatus` and `:checkhealth code-preview` to verify install state and the feature flag

**Copilot CLI hooks not firing**
- Run `:CodePreviewInstallCopilotCliHooks` in the project root
- Verify `.github/hooks/code-preview.json` exists
Expand Down
90 changes: 90 additions & 0 deletions backends/codex/code-close-diff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# code-close-diff.sh — PostToolUse hook adapter for OpenAI Codex CLI.
#
# Mirrors the translation in code-preview-diff.sh and delegates to
# bin/core-post-tool.sh. Only the fields core-post-tool.sh reads are
# populated (tool_name, cwd, file_path or patch_text).

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DIR="$SCRIPT_DIR/../../bin"
export CODE_PREVIEW_BACKEND="codex"

INPUT="$(cat)"

TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')"

case "$TOOL" in
""|read|view|glob|grep|ls|list_files) exit 0 ;;
esac
case "$TOOL" in
mcp__*) exit 0 ;;
esac

log() { :; }
# shellcheck source=/dev/null
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
# shellcheck source=/dev/null
source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true
if [[ -n "${NVIM_SOCKET:-}" ]]; then
_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}')
_DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null)
_LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null)
if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then
log() { printf '[%s] [INFO] codex/post: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; }
fi
fi

log "tool=$TOOL"

case "$TOOL" in
apply_patch)
PATCH="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')"
if [[ -z "$PATCH" ]]; then
log "apply_patch with empty/missing patch text — skipping"
exit 0
fi
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
tool_name: "ApplyPatch",
cwd: .cwd,
tool_input: { patch_text: (.tool_input.command // "") }
}')"
;;

ApplyPatch|Edit|Write)
FP="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""')"
if [[ -z "$FP" ]]; then
log "$TOOL with empty/missing file_path — skipping"
exit 0
fi
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
tool_name: .tool_name,
cwd: .cwd,
tool_input: .tool_input
}')"
;;

Bash)
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')"
if [[ -z "$CMD" ]]; then
log "Bash with empty/missing command — skipping"
exit 0
fi
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
tool_name: .tool_name,
cwd: .cwd,
tool_input: .tool_input
}')"
;;

*)
log "unhandled tool=$TOOL — exiting"
exit 0
;;
esac

log "translated tool=$TOOL → closing"

printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh"
114 changes: 114 additions & 0 deletions backends/codex/code-preview-diff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# code-preview-diff.sh — PreToolUse hook adapter for OpenAI Codex CLI.
#
# Translates Codex's hook payload (stdin JSON with tool_name/tool_input) into
# the normalized {tool_name, cwd, tool_input} format consumed by
# bin/core-pre-tool.sh, then delegates to it.
#
# Field mapping:
# apply_patch → ApplyPatch (tool_input.command holds the patch text;
# we move it under .patch_text)
# ApplyPatch → ApplyPatch (passthrough; canonical name)
# Edit → Edit (passthrough; assumes Claude-Code-style
# {file_path, old_string, new_string})
# Write → Write (passthrough; assumes {file_path, content})
# Bash → Bash (passthrough)
# read/glob/MCP/... → ignored
#
# Note: today's Codex models route all file edits through `apply_patch`. The
# Edit/Write branches exist defensively in case a future Codex version (or
# an MCP server) emits those names with Claude-Code-style field shapes.

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DIR="$SCRIPT_DIR/../../bin"
export CODE_PREVIEW_BACKEND="codex"

INPUT="$(cat)"

TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')"

# Skip noisy/no-op tools before the expensive socket/log-setup RPC.
case "$TOOL" in
""|read|view|glob|grep|ls|list_files) exit 0 ;;
esac
# MCP tools follow `mcp__server__name`; we don't preview them.
case "$TOOL" in
mcp__*) exit 0 ;;
esac

# Logging — mirrors copilot/code-preview-diff.sh. Gated on `debug = true`.
log() { :; }
# shellcheck source=/dev/null
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
# shellcheck source=/dev/null
source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true
if [[ -n "${NVIM_SOCKET:-}" ]]; then
_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}')
_DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null)
_LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null)
if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then
log() { printf '[%s] [INFO] codex/pre: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; }
fi
fi

log "tool=$TOOL cwd=$CWD"

case "$TOOL" in
apply_patch)
# Codex stores the raw `*** Begin Patch ... *** End Patch` text in
# tool_input.command. Our ApplyPatch handler in core-pre-tool.sh reads
# tool_input.patch_text, so move the field.
PATCH="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')"
if [[ -z "$PATCH" ]]; then
log "apply_patch with empty/missing patch text — skipping"
exit 0
fi
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
tool_name: "ApplyPatch",
cwd: .cwd,
tool_input: { patch_text: (.tool_input.command // "") }
}')"
;;

ApplyPatch|Edit|Write)
# Edit/Write-family tools require a non-empty file_path. Without it,
# core-pre-tool.sh would push a broken diff downstream.
FP="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""')"
if [[ -z "$FP" ]]; then
log "$TOOL with empty/missing file_path — skipping"
exit 0
fi
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
tool_name: .tool_name,
cwd: .cwd,
tool_input: .tool_input
}')"
;;

Bash)
# Bash needs a non-empty command to be useful (rm detection, shell-write
# detection both run on the command string).
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')"
if [[ -z "$CMD" ]]; then
log "Bash with empty/missing command — skipping"
exit 0
fi
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
tool_name: .tool_name,
cwd: .cwd,
tool_input: .tool_input
}')"
;;

*)
log "unhandled tool=$TOOL — exiting"
exit 0
;;
esac

log "translated tool=$TOOL → $(printf '%s' "$NORMALIZED" | jq -c '{tool_name, file: .tool_input.file_path // "", has_patch: (.tool_input.patch_text != null)}' 2>/dev/null || echo 'parse-error')"

printf '%s' "$NORMALIZED" | "$BIN_DIR/core-pre-tool.sh"
7 changes: 5 additions & 2 deletions bin/core-post-tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ fi

log_post "tool=$TOOL_NAME"

# For Bash tool (rm detection), only clear deletion markers — don't touch edit markers or diff tab
# For Bash tool, clear markers set by pre-hook detection (rm + shell writes).
# We use a distinct `bash_modified` status for shell writes so this clear
# doesn't clobber `modified` markers from concurrent Edit/Write/ApplyPatch
# operations whose post-hook hasn't fired yet.
if [[ "$TOOL_NAME" == "Bash" ]]; then
nvim_send "require('code-preview.changes').clear_by_status('deleted')" || true
nvim_send "require('code-preview.changes').clear_by_statuses({'deleted','bash_modified','bash_created'})" || true
nvim_send "vim.defer_fn(function() pcall(function() require('code-preview.neo_tree').refresh() end) end, 200)" || true
exit 0
fi
Expand Down
Loading
Loading