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
87 changes: 19 additions & 68 deletions backends/copilot/code-close-diff.sh
Original file line number Diff line number Diff line change
@@ -1,85 +1,36 @@
#!/usr/bin/env bash
# code-close-diff.sh — PostToolUse hook adapter for GitHub Copilot CLI.
# code-close-diff.sh — PostToolUse hook entry 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).
# Single RPC into the in-process orchestrator (lua/code-preview/post_tool.lua).
# The orchestrator clears the changes registry, closes any open preview for
# the affected file, and refreshes neo-tree.
#
# When Neovim is unreachable, the shim abstains silently (exit 0).

# No `set -e`: abstain on jq/nvim_call failure rather than surfacing a
# hook failure to the agent. See the matching note in code-preview-diff.sh.
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 // ""')"

# Fast-path filter — see the matching note in code-preview-diff.sh.
TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // empty' 2>/dev/null || true)"
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-call.sh" 2>/dev/null || true
if [[ -n "${NVIM_SOCKET:-}" ]]; then
_CTX="$(nvim_call code-preview.log state '[]' || 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
}
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"

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
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
source "$BIN_DIR/nvim-call.sh"

log "translated tool=$TOOL → closing"
if [[ -z "${NVIM_SOCKET:-}" ]]; then
exit 0
fi

printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh"
ARGS="$(jq -nc --argjson r "$INPUT" --arg b copilot '[$r, $b]' 2>/dev/null || true)"
[[ -z "$ARGS" ]] && exit 0
nvim_call code-preview.post_tool handle "$ARGS" >/dev/null
146 changes: 34 additions & 112 deletions backends/copilot/code-preview-diff.sh
Original file line number Diff line number Diff line change
@@ -1,129 +1,51 @@
#!/usr/bin/env bash
# code-preview-diff.sh — PreToolUse hook adapter for GitHub Copilot CLI.
# code-preview-diff.sh — PreToolUse hook entry 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.
# After issue #47 phase 3, this shim does almost nothing: it discovers the
# running Neovim's socket and makes a single RPC call into the in-process
# orchestrator (lua/code-preview/pre_tool/init.lua), then prints whatever the
# orchestrator returns. The bash that used to translate Copilot's
# {toolName, cwd, toolArgs} payload into the canonical hook shape now lives
# in lua/code-preview/pre_tool/normalisers.lua (copilot entry).
#
# 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.

# When Neovim is unreachable, the shim abstains: exit 0 with no stdout.
# Copilot then falls back to its native flow as if the plugin weren't
# installed. See docs/adr/0005-core-handler-runs-in-process.md.

# No `set -e`: the shim is the boundary between the agent and the plugin.
# When jq fails on a malformed payload or nvim_call returns rc=2, we want
# to exit 0 (abstain) so the agent falls back to its native flow rather
# than seeing a hook failure.
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.
# Fast-path filter for tools that never produce a preview. Copilot has no
# per-tool hook matcher and no TS-side allowlist (unlike Claude Code's
# settings.json and opencode's TS plugin), so every tool firing — including
# the very chatty view/glob/grep/ls/report_intent — would otherwise pay for
# socket discovery + an RPC round-trip just for the Lua normaliser to return
# tool_name=nil. The Lua map in pre_tool.normalisers remains the source of
# truth; this case is purely a perf filter.
TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // empty' 2>/dev/null || true)"
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-call.sh" 2>/dev/null || true
_NVIM_SERVERNAME=""
_NVIM_CWD=""
if [[ -n "${NVIM_SOCKET:-}" ]]; then
_CTX="$(nvim_call code-preview.log state '[]' || 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
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"

# 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
# Socket discovery — silent failure is fine, we abstain below.
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
source "$BIN_DIR/nvim-call.sh"

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')"
if [[ -z "${NVIM_SOCKET:-}" ]]; then
exit 0
fi

printf '%s' "$NORMALIZED" | "$BIN_DIR/core-pre-tool.sh"
ARGS="$(jq -nc --argjson r "$INPUT" --arg b copilot '[$r, $b]' 2>/dev/null || true)"
# Malformed payload (jq couldn't parse) — abstain silently.
[[ -z "$ARGS" ]] && exit 0
nvim_call code-preview.pre_tool handle "$ARGS"
3 changes: 2 additions & 1 deletion lua/code-preview/pre_tool/emitters.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ end
M.emitters = {
claudecode = claudecode,
opencode = none,
-- codex / copilot / gemini default to `none` via the fallback below.
copilot = none,
-- codex / gemini default to `none` via the fallback below.
}

--- @param backend string
Expand Down
93 changes: 92 additions & 1 deletion lua/code-preview/pre_tool/normalisers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,101 @@ local function opencode(raw)
}
end

-- Copilot tools as of 2026-05-21: apply_patch, edit, str_replace, create,
-- write, bash (plus view/glob/grep/ls/report_intent which the shim drops
-- before invoking us). `str_replace` and `edit` carry the same {path,
-- old_str, new_str} shape; both alias to Edit. `create` and `write` both
-- alias to Write (file_text vs content).
local COPILOT_TOOL_MAP = {
apply_patch = "ApplyPatch",
edit = "Edit",
str_replace = "Edit",
create = "Write",
write = "Write",
bash = "Bash",
}

-- Copilot delivers `toolArgs` as a JSON-encoded string in preToolUse and as
-- an object in postToolUse. For apply_patch the string IS the raw patch text
-- (not JSON). For every other tool the string contains a JSON object with
-- snake_case keys (path, old_str, new_str, file_text, command, ...).
--
-- Note: file paths are run through the shared `resolve_path`, which collapses
-- ../ and ./ segments via vim.fs.normalize. The old bash copilot shim did
-- not — paths were preserved verbatim. The change is deliberate and matches
-- opencode's contract: internal keys (active_diffs, changes registry) must
-- be canonical so logically-same files compare equal across backends.
local function copilot(raw)
local tool = (raw and raw.toolName) or ""
local cwd = (raw and raw.cwd) or ""
local args = raw and raw.toolArgs

local canonical_tool = COPILOT_TOOL_MAP[tool]

local args_string, args_table
if type(args) == "string" then
args_string = args
if canonical_tool ~= "ApplyPatch" then
local ok, decoded = pcall(vim.json.decode, args)
if ok and type(decoded) == "table" then args_table = decoded end
end
elseif type(args) == "table" then
args_table = args
if canonical_tool == "ApplyPatch" then
-- Unusual but possible in postToolUse: mirror the bash adapter which
-- stringified the object via tojson so downstream parsing is uniform.
args_string = vim.json.encode(args)
end
end
args_table = args_table or {}

-- Defensive: the old bash shim explicitly skipped Edit/Write with an empty
-- file path and Bash with an empty command. Carry that contract forward —
-- otherwise a `{path: ""}` payload reaches diff.show_diff with file_path=""
-- and opens a broken diff tab. Drop the tool_name so the dispatcher no-ops.
local function blank(s) return s == nil or s == "" end

local tool_input = {}
if canonical_tool == "ApplyPatch" then
if blank(args_string) then
return { tool_name = nil, cwd = cwd, tool_input = {} }
end
tool_input.patch_text = args_string
elseif canonical_tool == "Bash" then
if blank(args_table.command) then
return { tool_name = nil, cwd = cwd, tool_input = {} }
end
tool_input.command = args_table.command
elseif canonical_tool == "Edit" then
local fp = resolve_path(args_table.path, cwd)
if blank(fp) then
return { tool_name = nil, cwd = cwd, tool_input = {} }
end
tool_input.file_path = fp
tool_input.old_string = args_table.old_str or ""
tool_input.new_string = args_table.new_str or ""
tool_input.replace_all = false
elseif canonical_tool == "Write" then
local fp = resolve_path(args_table.path, cwd)
if blank(fp) then
return { tool_name = nil, cwd = cwd, tool_input = {} }
end
tool_input.file_path = fp
tool_input.content = args_table.file_text or args_table.content or ""
end

return {
tool_name = canonical_tool,
cwd = cwd,
tool_input = tool_input,
}
end

M.normalisers = {
claudecode = identity,
opencode = opencode,
-- codex / copilot / gemini will land their own normalisers as they flip.
copilot = copilot,
-- codex / gemini will land their own normalisers as they flip.
}

--- @param raw table decoded hook payload
Expand Down
Loading
Loading