Skip to content
Open
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
17 changes: 6 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,19 +143,16 @@ require("code-preview").setup()
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:
4. 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.
> **Note on the hooks feature flag:** Modern Codex enables hooks by default — no `[features]` entry is required. If you previously disabled them with `[features] hooks = false` (or the legacy `codex_hooks = false`), remove that line. `:CodePreviewStatus` and `:checkhealth code-preview` will warn if hooks are explicitly disabled.
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
Expand Down Expand Up @@ -187,7 +184,7 @@ 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`.
**OpenAI Codex CLI** uses shell-based hooks (`PreToolUse`/`PostToolUse`) configured in `.codex/hooks.json`. Hooks are enabled by default in modern Codex; the only way to silence them is `[features] hooks = false` (or the legacy `codex_hooks = false`) 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 @@ -250,8 +247,6 @@ require("code-preview").setup({
| `:CodePreviewToggleVisibleOnly` | Toggle visible_only — show diffs only for open buffers |
| `: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.

## Keymaps

| Key | Scope | Description |
Expand Down Expand Up @@ -450,8 +445,8 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, {

**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`
- Confirm `.codex/config.toml` does **not** contain `[features] hooks = false` (or the legacy `codex_hooks = false`) — hooks are enabled by default in modern Codex; an explicit `false` is the only way to silence them
- Update Codex if needed — older versions required `codex_hooks = true` explicitly and 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**
Expand All @@ -466,7 +461,7 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, {
**Migrating from older versions**
- Update `require("claude-preview")` to `require("code-preview")` in your Neovim config
- Re-run `:CodePreviewInstallClaudeCodeHooks` to update hook paths
- The old `:ClaudePreview*` commands still work but show deprecation warnings
- The old `:ClaudePreview*` commands have been removed — use the `:CodePreview*` equivalents

---

Expand Down
14 changes: 14 additions & 0 deletions lua/code-preview/backends/claudecode.lua
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ function M.install()
vim.notify("[code-preview] Hooks installed → " .. path, vim.log.levels.INFO)
end

--- Report whether the Claude Code hooks are wired up in this project.
--- @return { state: "installed"|"missing", warnings: string[]? }
function M.install_state()
local path = settings_path()
local f = io.open(path, "r")
if not f then return { state = "missing" } end
local content = f:read("*a") or ""
f:close()
local installed = content:find(HOOK_MARKER, 1, true) ~= nil
or content:find(LEGACY_HOOK_MARKER, 1, true) ~= nil
if installed then return { state = "installed" } end
return { state = "missing" }
end

function M.uninstall()
local path = settings_path()
local data = read_settings(path)
Expand Down
89 changes: 50 additions & 39 deletions lua/code-preview/backends/codex.lua
Original file line number Diff line number Diff line change
Expand Up @@ -83,25 +83,28 @@ local function remove_ours(list)
return filtered
end

-- Check both the project-local and global config.toml for the codex_hooks
-- feature flag. Returns "enabled" | "disabled" | "missing".
-- enabled — at least one location has `codex_hooks = true`
-- disabled — at least one location exists, but none enable the flag
-- missing — neither location exists
-- The global path mirrors what Codex itself reads, so a user who set the
-- flag in ~/.codex/config.toml shouldn't see a false warning here.
-- Per-file probe for the Codex hooks feature flag. Returns one of:
-- "enabled" — file explicitly sets `hooks` (or legacy `codex_hooks`) to true
-- "disabled" — file explicitly sets the same to false
-- nil — file is missing or expresses no opinion (use default)
-- Modern Codex enables hooks by default and accepts either `hooks` (canonical)
-- or `codex_hooks` (deprecated alias) under [features]. We match either.
local function file_flag_state(path)
if vim.fn.filereadable(path) == 0 then return "missing" end
if vim.fn.filereadable(path) == 0 then return nil end
local f = io.open(path, "r")
if not f then return "missing" end
if not f then return nil end
local content = f:read("*a") or ""
f:close()
-- Look for `codex_hooks = true` (loose match — handles whitespace & quotes
-- but not deeply parsed; users with exotic TOML are responsible for it).
if content:match("codex_hooks%s*=%s*true") then
-- Loose match — handles whitespace & quotes but not deeply parsed; users
-- with exotic TOML are responsible for it. Order matters only for the
-- explicit-false detection: we surface disabled iff no enabling line exists.
if content:match("hooks%s*=%s*true") or content:match("codex_hooks%s*=%s*true") then
return "enabled"
end
return "disabled"
if content:match("hooks%s*=%s*false") or content:match("codex_hooks%s*=%s*false") then
return "disabled"
end
return nil
end

local function global_config_path()
Expand All @@ -112,20 +115,16 @@ local function global_config_path()
return vim.fn.expand("~/.codex/config.toml")
end

--- Resolve the effective state of Codex's `hooks` feature flag.
--- Project-local config wins over the global config; in the absence of any
--- explicit setting, Codex defaults to enabled, so we do too.
--- @return "enabled"|"disabled"
local function feature_flag_state()
local local_state = file_flag_state(config_path())
local local_state = file_flag_state(config_path())
if local_state ~= nil then return local_state end
local global_state = file_flag_state(global_config_path())
-- Enabled wins if either location turns it on.
if local_state == "enabled" or global_state == "enabled" then
return "enabled"
end
-- If at least one file exists but neither enables the flag, surface as
-- disabled (so we tell the user what to fix). Only report missing when
-- both files are absent.
if local_state == "missing" and global_state == "missing" then
return "missing"
end
return "disabled"
if global_state ~= nil then return global_state end
return "enabled"
end

local function ensure_executable(path)
Expand Down Expand Up @@ -173,20 +172,16 @@ function M.install()
write_json(hooks_path(), data)
vim.notify("[code-preview] Codex hooks installed → " .. hooks_path(), vim.log.levels.INFO)

-- Codex ignores hooks.json unless `codex_hooks = true` lives under
-- `[features]` in config.toml. We don't edit config.toml automatically
-- (TOML editing without a parser is risky); surface a clear nudge instead.
local state = feature_flag_state()
if state ~= "enabled" then
local msg
if state == "missing" then
msg = "[code-preview] Codex requires a feature flag to enable hooks. Create "
.. config_path() .. " with:\n\n [features]\n codex_hooks = true\n"
else
msg = "[code-preview] Codex requires `codex_hooks = true` under `[features]` in "
.. config_path() .. ". Add it manually before running Codex."
end
vim.notify(msg, vim.log.levels.WARN)
-- Modern Codex enables hooks by default — no config.toml entry needed. We
-- only nudge the user if they've *explicitly* opted out via `hooks = false`
-- (or the legacy `codex_hooks = false`) under `[features]`.
if feature_flag_state() == "disabled" then
vim.notify(
"[code-preview] Codex hooks are disabled in your config: `[features] hooks = false` (or `codex_hooks = false`) is set in "
.. config_path() .. " or " .. global_config_path()
.. ". Remove the line, or set `hooks = true`, before running Codex.",
vim.log.levels.WARN
)
end
end

Expand Down Expand Up @@ -221,6 +216,22 @@ end
-- without duplicating the parser.
function M.feature_flag_state() return feature_flag_state() end

--- Report Codex install state. Hooks-wired-up is the primary signal. Modern
--- Codex enables hooks by default, so we only warn when the user has
--- explicitly disabled them via `[features] hooks = false` (or the legacy
--- `codex_hooks = false`) in config.toml.
--- @return { state: "installed"|"missing", warnings: string[]? }
function M.install_state()
if not M.is_installed() then return { state = "missing" } end
if feature_flag_state() == "disabled" then
return {
state = "installed",
warnings = { "hooks explicitly disabled in .codex/config.toml (`[features] hooks = false`)" },
}
end
return { state = "installed" }
end

-- True iff `path`'s hooks.json contains an entry referencing our adapter
-- script. Used by status display to detect installation without relying on
-- file existence alone.
Expand Down
9 changes: 9 additions & 0 deletions lua/code-preview/backends/copilot.lua
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ function M.install()
vim.notify("[code-preview] Copilot CLI hooks installed → " .. path, vim.log.levels.INFO)
end

--- Report whether the Copilot CLI hooks config was produced by our installer.
--- @return { state: "installed"|"missing", warnings: string[]? }
function M.install_state()
if M.is_our_config(config_path()) then
return { state = "installed" }
end
return { state = "missing" }
end

function M.uninstall()
local path = config_path()
if vim.fn.filereadable(path) == 0 then
Expand Down
9 changes: 9 additions & 0 deletions lua/code-preview/backends/opencode.lua
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ function M.install()
vim.notify("[code-preview] OpenCode plugin installed → " .. target, vim.log.levels.INFO)
end

--- Report whether the OpenCode plugin has been copied into the project.
--- @return { state: "installed"|"missing", warnings: string[]? }
function M.install_state()
if vim.fn.filereadable(opencode_target_dir() .. "/index.ts") == 1 then
return { state = "installed" }
end
return { state = "missing" }
end

function M.uninstall()
local target = opencode_target_dir()

Expand Down
12 changes: 6 additions & 6 deletions lua/code-preview/health.lua
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,13 @@ function M.check()
local codex_backend = require("code-preview.backends.codex")
if codex_backend.is_installed() then
ok("Codex CLI hooks are installed (.codex/hooks.json)")
local flag = codex_backend.feature_flag_state()
if flag == "enabled" then
ok(".codex/config.toml has codex_hooks = true")
elseif flag == "disabled" then
warn(".codex/config.toml is missing `codex_hooks = true` under [features] — hooks will not fire")
-- Modern Codex enables hooks by default under [features]; the canonical
-- key is `hooks` (with `codex_hooks` accepted as a deprecated alias).
-- The only failure mode here is the user having explicitly opted out.
if codex_backend.feature_flag_state() == "disabled" then
warn("Codex hooks are explicitly disabled in config.toml (`[features] hooks = false`) — remove it or set `hooks = true`")
else
warn(".codex/config.toml not found — create it with `[features]\\ncodex_hooks = true`")
ok("Codex hooks feature is enabled (default; no flag required)")
end
else
warn("Codex CLI hooks not installed — run :CodePreviewInstallCodexCliHooks")
Expand Down
87 changes: 19 additions & 68 deletions lua/code-preview/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,6 @@ local function deep_merge(base, override)
return result
end

-- Helper: create a deprecated alias that warns and delegates
local function deprecated_alias(old_name, new_name)
vim.api.nvim_create_user_command(old_name, function()
vim.notify(
"[code-preview] :" .. old_name .. " is deprecated, use :" .. new_name,
vim.log.levels.WARN
)
vim.cmd(new_name)
end, { desc = "(deprecated) Use :" .. new_name .. " instead" })
end

function M.setup(user_config)
M.config = deep_merge(default_config, user_config or {})

Expand Down Expand Up @@ -150,14 +139,6 @@ function M.setup(user_config)
)
end, { desc = "Toggle visible_only — show diffs only for open buffers vs all files" })

-- ── Deprecated aliases (remove after one release cycle) ───────

deprecated_alias("ClaudePreviewInstallHooks", "CodePreviewInstallClaudeCodeHooks")
deprecated_alias("ClaudePreviewUninstallHooks", "CodePreviewUninstallClaudeCodeHooks")
deprecated_alias("ClaudePreviewCloseDiff", "CodePreviewCloseDiff")
deprecated_alias("ClaudePreviewStatus", "CodePreviewStatus")
deprecated_alias("ClaudePreviewToggleVisibleOnly", "CodePreviewToggleVisibleOnly")

-- Neo-tree integration (soft dependency)
if M.config.neo_tree.enabled then
require("code-preview.neo_tree").setup(M.config)
Expand Down Expand Up @@ -250,60 +231,30 @@ function M.status()
local diff = require("code-preview.diff")
table.insert(lines, "Diff tab : " .. (diff.is_open() and "open" or "closed"))

-- Backends
-- Backends — each module exposes install_state() returning
-- { state = "installed"|"missing", warnings = {...}? }. Rendering lives
-- here; per-backend detection logic lives in the backend module.
table.insert(lines, "")
table.insert(lines, "Backends:")

-- Claude Code
local claude_ok = false
local settings_path = vim.fn.getcwd() .. "/.claude/settings.local.json"
local f = io.open(settings_path, "r")
if f then
local content = f:read("*a")
f:close()
claude_ok = content:find("code-preview", 1, true) ~= nil
or content:find("claude-preview", 1, true) ~= nil
end
if claude_ok then
table.insert(lines, " Claude Code : installed")
else
table.insert(lines, " Claude Code : not installed -> :CodePreviewInstallClaudeCodeHooks")
end

-- OpenCode
local opencode_ok = vim.fn.filereadable(vim.fn.getcwd() .. "/.opencode/plugins/index.ts") == 1
if opencode_ok then
table.insert(lines, " OpenCode : installed")
else
table.insert(lines, " OpenCode : not installed -> :CodePreviewInstallOpenCodeHooks")
end

-- Copilot CLI — check file contents, not just existence, so a user-authored
-- hook file that happens to share the name isn't reported as "installed".
local copilot_ok = require("code-preview.backends.copilot").is_our_config(
vim.fn.getcwd() .. "/.github/hooks/code-preview.json"
)
if copilot_ok then
table.insert(lines, " Copilot CLI : installed")
else
table.insert(lines, " Copilot CLI : not installed -> :CodePreviewInstallCopilotCliHooks")
end

-- Codex CLI — installation requires both our hooks.json entries AND the
-- `codex_hooks = true` feature flag in config.toml; report both so users
-- can debug a "hooks aren't firing" state without guessing.
local codex = require("code-preview.backends.codex")
if codex.is_installed() then
local flag = codex.feature_flag_state()
if flag == "enabled" then
table.insert(lines, " Codex CLI : installed (codex_hooks=true)")
elseif flag == "disabled" then
table.insert(lines, " Codex CLI : installed BUT codex_hooks flag missing in .codex/config.toml")
local BACKENDS = {
{ name = "claudecode", label = "Claude Code", install_cmd = ":CodePreviewInstallClaudeCodeHooks" },
{ name = "opencode", label = "OpenCode ", install_cmd = ":CodePreviewInstallOpenCodeHooks" },
{ name = "copilot", label = "Copilot CLI", install_cmd = ":CodePreviewInstallCopilotCliHooks" },
{ name = "codex", label = "Codex CLI ", install_cmd = ":CodePreviewInstallCodexCliHooks" },
}

for _, b in ipairs(BACKENDS) do
local s = require("code-preview.backends." .. b.name).install_state()
if s.state == "installed" then
if s.warnings and #s.warnings > 0 then
table.insert(lines, " " .. b.label .. " : installed BUT " .. table.concat(s.warnings, "; "))
else
table.insert(lines, " " .. b.label .. " : installed")
end
else
table.insert(lines, " Codex CLI : installed BUT .codex/config.toml not found (need codex_hooks=true)")
table.insert(lines, " " .. b.label .. " : not installed -> " .. b.install_cmd)
end
else
table.insert(lines, " Codex CLI : not installed -> :CodePreviewInstallCodexCliHooks")
end

vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO, { title = "code-preview" })
Expand Down
Loading
Loading