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 @@ -7,5 +7,8 @@
# OpenCode local plugins (installed by :CodePreviewInstallOpenCodeHooks)
.opencode/

# Copilot CLI local hooks (installed by :CodePreviewInstallCopilotCliHooks)
.github/hooks/code-preview.json

# Test dependencies (plenary.nvim, installed by tests/run_lua.sh)
deps/
51 changes: 42 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

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) and [OpenCode](https://opencode.ai) as backends.
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.

---

## Demo

### Claude Code
![Claude Code demo](docs/claude-preview-demo.gif)
![Claude Code demo](docs/code-preview-claudecode.gif)

### OpenCode
![OpenCode demo](docs/claude-preview-opencode.gif)
![OpenCode demo](docs/code-preview-opencode.gif)

### GitHub Copilot CLI
![GitHub Copilot CLI demo](docs/code-preview-copilot.gif)

---

Expand All @@ -24,6 +27,7 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Open
- [Quick Start](#quick-start)
- [Claude Code](#claude-code)
- [OpenCode](#opencode)
- [GitHub Copilot CLI](#github-copilot-cli)
- [How it works](#how-it-works)
- [Configuration](#configuration)
- [Commands](#commands)
Expand Down Expand Up @@ -55,6 +59,10 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Open
**For OpenCode backend:**
- [OpenCode](https://opencode.ai) >= 1.3.0

**For GitHub Copilot CLI backend:**
- [GitHub Copilot CLI](https://github.com/github/copilot-cli) (generally available since Feb 2026)
- [jq](https://jqlang.github.io/jq/) — for hook payload translation

---

## Installation
Expand Down Expand Up @@ -110,6 +118,18 @@ require("code-preview").setup()
7. Accept/reject in OpenCode; the diff closes automatically on accept
8. If rejected, press `<leader>dq` to close the diff manually

### GitHub Copilot CLI

1. Install the plugin and call `setup()`
2. Open a project in Neovim
3. Run `:CodePreviewInstallCopilotCliHooks` — writes `.github/hooks/code-preview.json`
4. Start Copilot CLI in the project directory
5. Ask Copilot to edit a file — a diff opens automatically in Neovim
6. Accept/reject in the CLI; the diff closes automatically on accept
7. If rejected, press `<leader>dq` to close the diff manually

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

---

## How it works
Expand All @@ -132,7 +152,9 @@ AI Agent (terminal) Neovim

**OpenCode** uses a TypeScript plugin (`tool.execute.before`/`tool.execute.after`) loaded from `.opencode/plugins/`.

Both backends communicate with Neovim via RPC (`nvim --server <socket> --remote-send`).
**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.

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

---

Expand Down Expand Up @@ -184,6 +206,8 @@ require("code-preview").setup({
| `:CodePreviewUninstallClaudeCodeHooks` | Remove Claude Code hooks (leaves other hooks intact) |
| `:CodePreviewInstallOpenCodeHooks` | Install OpenCode plugin to `.opencode/plugins/` |
| `:CodePreviewUninstallOpenCodeHooks` | Remove OpenCode plugin |
| `:CodePreviewInstallCopilotCliHooks` | Install Copilot CLI hooks to `.github/hooks/code-preview.json` |
| `:CodePreviewUninstallCopilotCliHooks` | Remove Copilot 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 |
Expand Down Expand Up @@ -230,7 +254,7 @@ require("code-preview").setup({

If you use [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim), code-preview will automatically decorate your file tree with visual indicators when changes are proposed. No extra configuration is required — it works out of the box.

![neo-tree integration demo](docs/claude-preview-neotree-integration.gif)
![neo-tree integration demo](docs/code-preview-neotree-integration.gif)

### What you get

Expand Down Expand Up @@ -301,10 +325,13 @@ code-preview.nvim/
│ ├── claudecode/ Claude Code adapter
│ │ ├── code-preview-diff.sh PreToolUse hook entry point
│ │ └── code-close-diff.sh PostToolUse hook entry point
│ └── opencode/ OpenCode adapter
│ ├── index.ts tool.execute.before/after hooks
│ ├── package.json
│ └── tsconfig.json
│ ├── opencode/ OpenCode adapter
│ │ ├── 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
```

---
Expand Down Expand Up @@ -357,6 +384,12 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, {
- Ensure `"permission": { "edit": "ask" }` is set in `~/.config/opencode/opencode.json`
- Restart OpenCode

**Copilot CLI hooks not firing**
- Run `:CodePreviewInstallCopilotCliHooks` in the project root
- Verify `.github/hooks/code-preview.json` exists
- Ensure `jq` is in PATH
- Restart Copilot CLI (hooks are loaded at session start)

**Diff doesn't close after rejecting**
- Press `<leader>dq` or run `:CodePreviewCloseDiff` — the post hook only fires on accept

Expand Down
85 changes: 85 additions & 0 deletions backends/copilot/code-close-diff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# code-close-diff.sh — PostToolUse hook adapter for GitHub Copilot 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="copilot"

INPUT="$(cat)"

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

case "$TOOL" in
""|view|glob|grep|ls|report_intent) exit 0 ;;
esac

# Logging — gated on `debug = true` in setup().
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] copilot/post: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; }
fi
fi

log "tool=$TOOL"

RAW_ARGS="$(printf '%s' "$INPUT" | jq -r '.toolArgs // "" | if type == "string" then . else tojson end')"

# Bind the key as data via --arg, not interpolated into the jq program.
# Supports single-key lookup only (no dotted paths) — all current callers
# pass a single field like `.path`, `.command`, etc.
arg() { printf '%s' "$RAW_ARGS" | jq -r --arg k "${1#.}" '.[$k] // ""'; }

resolve_path() {
local p="$1"
if [[ -z "$p" ]]; then printf ''; return; fi
if [[ "$p" != /* ]]; then printf '%s/%s' "$CWD" "$p"; else printf '%s' "$p"; fi
}

case "$TOOL" in
apply_patch)
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg patch "$RAW_ARGS" \
'{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}')"
;;

edit|str_replace)
FP="$(resolve_path "$(arg .path)")"
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" \
'{tool_name:"Edit", cwd:$cwd, tool_input:{file_path:$fp}}')"
;;

create|write)
FP="$(resolve_path "$(arg .path)")"
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" \
'{tool_name:"Write", cwd:$cwd, tool_input:{file_path:$fp}}')"
;;

bash)
CMD="$(arg .command)"
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg cmd "$CMD" \
'{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}')"
;;

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

log "translated tool=$TOOL → closing"

printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh"
129 changes: 129 additions & 0 deletions backends/copilot/code-preview-diff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# code-preview-diff.sh — PreToolUse hook adapter for GitHub Copilot CLI.
#
# Translates Copilot's hook payload (stdin JSON with toolName/toolArgs) 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 (toolArgs is raw patch text)
# edit/str_replace → Edit ({path, old_str, new_str})
# create/write → Write ({path, file_text | content})
# bash → Bash ({command, description})
# view/glob/... → ignored
#
# Note: toolArgs is a JSON-encoded string in preToolUse and an object in
# postToolUse; we normalize both to a string so downstream parsing is uniform.

set -uo pipefail

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

INPUT="$(cat)"

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

# Noise tools never produce a preview — bail out before the expensive
# socket/log-setup RPC so the log stays clean.
case "$TOOL" in
""|view|glob|grep|ls|report_intent) exit 0 ;;
esac

# Logging — mirrors core-pre-tool.sh. Gated on `debug = true` in setup().
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
_NVIM_SERVERNAME=""
_NVIM_CWD=""
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 '',servername=vim.v.servername,cwd=vim.fn.getcwd()})\")" 2>/dev/null || echo '{}')
_DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null)
_LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null)
_NVIM_SERVERNAME=$(echo "$_CTX" | jq -r '.servername // ""' 2>/dev/null)
_NVIM_CWD=$(echo "$_CTX" | jq -r '.cwd // ""' 2>/dev/null)
if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then
log() { printf '[%s] [INFO] copilot/pre: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; }
fi
fi

log "tool=$TOOL servername=${_NVIM_SERVERNAME:-<none>} nvim_cwd=${_NVIM_CWD:-<none>} hook_cwd=$CWD"

# Normalize toolArgs to a raw string. For JSON-object tools this becomes the
# stringified JSON; for apply_patch it's the raw patch text.
RAW_ARGS="$(printf '%s' "$INPUT" | jq -r '.toolArgs // "" | if type == "string" then . else tojson end')"

# Bind the key as data via --arg, not interpolated into the jq program.
# Supports single-key lookup only (no dotted paths) — all current callers
# pass a single field like `.path`, `.command`, etc.
arg() { printf '%s' "$RAW_ARGS" | jq -r --arg k "${1#.}" '.[$k] // ""'; }

resolve_path() {
local p="$1"
if [[ -z "$p" ]]; then printf ''; return; fi
if [[ "$p" != /* ]]; then printf '%s/%s' "$CWD" "$p"; else printf '%s' "$p"; fi
}

case "$TOOL" in
apply_patch)
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg patch "$RAW_ARGS" \
'{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}')"
;;

edit|str_replace)
FP="$(resolve_path "$(arg .path)")"
NORMALIZED="$(jq -n \
--arg cwd "$CWD" \
--arg fp "$FP" \
--arg os "$(arg .old_str)" \
--arg ns "$(arg .new_str)" \
'{tool_name:"Edit", cwd:$cwd,
tool_input:{file_path:$fp, old_string:$os, new_string:$ns, replace_all:false}}')"
;;

create|write)
FP="$(resolve_path "$(arg .path)")"
# Copilot's create uses file_text; fall back to content for other models.
CONTENT="$(printf '%s' "$RAW_ARGS" | jq -r '.file_text // .content // ""')"
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" --arg c "$CONTENT" \
'{tool_name:"Write", cwd:$cwd, tool_input:{file_path:$fp, content:$c}}')"
;;

bash)
CMD="$(arg .command)"
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg cmd "$CMD" \
'{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}')"
;;

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

# Guard against malformed payloads (missing toolArgs fields). Sending an
# empty path or command downstream produces a broken/empty diff; a clean
# skip is preferable. apply_patch is already resilient — apply-patch.lua
# parses zero files from an empty patch and exits cleanly.
case "$TOOL" in
edit|str_replace|create|write)
if [[ -z "$FP" ]]; then
log "empty file path for tool=$TOOL — skipping"
exit 0
fi
;;
bash)
if [[ -z "$CMD" ]]; then
log "empty command for bash — skipping"
exit 0
fi
;;
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"
14 changes: 12 additions & 2 deletions bin/apply-patch.lua
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,18 @@ for line in (patch_text .. "\n"):gmatch("([^\n]*)\n") do
table.insert(current_file.hunks, current_file.current_hunk)
elseif line == "*** End Patch" or line == "*** Begin Patch" then
current_file = nil
elseif current_file and current_file.current_hunk then
table.insert(current_file.current_hunk.lines, line)
elseif current_file then
-- `*** Add File:` in the GPT patch format has no `@@` marker — content
-- lines follow directly. Lazy-create a hunk on the first content line
-- so those lines aren't dropped, without leaving an empty leading hunk
-- when `@@` *is* present.
if not current_file.current_hunk and current_action == "add" then
current_file.current_hunk = { lines = {} }
table.insert(current_file.hunks, current_file.current_hunk)
end
if current_file.current_hunk then
table.insert(current_file.current_hunk.lines, line)
end
end
end

Expand Down
8 changes: 5 additions & 3 deletions bin/core-post-tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
# "tool_input": { "file_path": "...", ... } }
#
# Environment:
# CODE_PREVIEW_BACKEND — "claudecode" or "opencode" (currently unused, reserved)
# CODE_PREVIEW_BACKEND — "claudecode" | "opencode" | "copilot". Not read
# by this script; kept set by adapters for symmetry
# with core-pre-tool.sh, which does gate on it.

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

Expand Down Expand Up @@ -56,8 +58,8 @@ if [[ "$TOOL_NAME" == "ApplyPatch" ]]; then
[[ "$fpath" == "/dev/null" ]] && continue
echo "$fpath"
done
echo "$1" | grep -E '^\*\*\* (Update|Add) File:' | while IFS= read -r line; do
echo "$line" | sed -E 's/^\*\*\* (Update|Add) File:[[:space:]]*//' | sed 's/[[:space:]]*$//'
echo "$1" | grep -E '^\*\*\* (Update|Add|Delete) File:' | while IFS= read -r line; do
echo "$line" | sed -E 's/^\*\*\* (Update|Add|Delete) File:[[:space:]]*//' | sed 's/[[:space:]]*$//'
done
}

Expand Down
Loading
Loading