Skip to content

Commit a6dada9

Browse files
committed
feat: auto-close diff preview on tool rejection
When the user rejects an Edit/Write/MultiEdit at Claude Code's permission prompt, the diff preview tab now closes automatically. No CC hook fires after manual rejection (empirically confirmed: abortController.abort() kills the turn dead). However, the rejection IS written to the session transcript JSONL as a tool_result with is_error:true. The PreToolUse hook input includes transcript_path and tool_use_id, which uniquely identify the pending tool call. Primary mechanism: PreToolUse spawns a background watcher that tail -F's the session transcript JSONL, matching the specific tool_use_id + is_error:true. On match, closes the diff via nvim RPC. The watcher is scoped to the specific session transcript, so rejections from other CC sessions do not affect unrelated diffs. Self-terminates on: acceptance (stopfile from PostToolUse), rejection match, diff manually closed, nvim death, or 120s timeout. Fallback: UserPromptSubmit hook checks is_open() and closes any orphaned diff when the user sends their next message. Includes 24 headless integration tests covering rejection detection, watcher lifecycle, multi-session isolation, rapid accept races, unfocused tab handling, tail death recovery, and state cleanup. All tests report wall-clock timing for critical paths. Closes #29
1 parent 866dc4b commit a6dada9

5 files changed

Lines changed: 1142 additions & 0 deletions

File tree

bin/claude-close-diff.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
88
INPUT="$(cat)"
99
CWD="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"
1010
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)"
11+
TRANSCRIPT_PATH="$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
12+
TOOL_USE_ID="$(echo "$INPUT" | jq -r '.tool_use_id // empty' 2>/dev/null || true)"
1113

1214
# Discover Neovim socket (prefer instance whose cwd matches project) and load RPC helpers
1315
source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null
1416
source "$SCRIPT_DIR/nvim-send.sh"
17+
source "$SCRIPT_DIR/claude-preview-transcript-watch.sh"
1518

1619
# For Bash tool (rm detection), only clear deletion markers — don't touch edit markers or diff tab
1720
if [[ "$TOOL_NAME" == "Bash" ]]; then
@@ -38,4 +41,9 @@ fi
3841
# Clean up temp files
3942
rm -f "${TMPDIR:-/tmp}/claude-diff-original" "${TMPDIR:-/tmp}/claude-diff-proposed"
4043

44+
# Stop the transcript watcher (if one was spawned by PreToolUse)
45+
if [[ -n "$TRANSCRIPT_PATH" && -n "$TOOL_USE_ID" ]]; then
46+
claude_preview_stop_transcript_watcher "$TRANSCRIPT_PATH" "$TOOL_USE_ID" || true
47+
fi
48+
4149
exit 0

bin/claude-preview-diff.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ INPUT="$(cat)"
1212

1313
TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name')"
1414
CWD="$(echo "$INPUT" | jq -r '.cwd')"
15+
TRANSCRIPT_PATH="$(echo "$INPUT" | jq -r '.transcript_path // empty')"
16+
TOOL_USE_ID="$(echo "$INPUT" | jq -r '.tool_use_id // empty')"
1517

1618
# Discover Neovim socket (prefer instance whose cwd matches project) and load RPC helpers
1719
source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
1820
source "$SCRIPT_DIR/nvim-send.sh"
21+
source "$SCRIPT_DIR/claude-preview-transcript-watch.sh"
1922

2023
HAS_NVIM=true
2124
if [[ -z "${NVIM_SOCKET:-}" ]]; then
@@ -190,6 +193,14 @@ if [[ "$HAS_NVIM" == "true" ]]; then
190193
fi
191194

192195
nvim_send "require('claude-preview.diff').show_diff('$ORIG_ESC', '$PROP_ESC', '$DISPLAY_ESC')" || true
196+
197+
# Spawn transcript watcher to close diff on rejection
198+
if [[ -n "$TRANSCRIPT_PATH" && -n "$TOOL_USE_ID" ]]; then
199+
(
200+
trap '' HUP
201+
claude_preview_watch_transcript "$TRANSCRIPT_PATH" "$TOOL_USE_ID"
202+
) >/dev/null 2>&1 &
203+
fi
193204
fi
194205

195206
# --- Always ask for user confirmation ---
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env bash
2+
# claude-preview-transcript-watch.sh — shared transcript watcher helpers
3+
#
4+
# Sourced by claude-preview-diff.sh and claude-close-diff.sh.
5+
6+
claude_preview_watch_state_key() {
7+
local transcript_path="$1"
8+
local tool_use_id="$2"
9+
printf '%s\0%s' "$transcript_path" "$tool_use_id" | cksum | awk '{print $1}'
10+
}
11+
12+
claude_preview_watch_state_dir() {
13+
local transcript_path="$1"
14+
local tool_use_id="$2"
15+
local key
16+
key="$(claude_preview_watch_state_key "$transcript_path" "$tool_use_id")"
17+
printf '%s/claude-preview-watch-%s' "${TMPDIR:-/tmp}" "$key"
18+
}
19+
20+
claude_preview_watch_pidfile() {
21+
local transcript_path="$1"
22+
local tool_use_id="$2"
23+
printf '%s/pid' "$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")"
24+
}
25+
26+
claude_preview_watch_stopfile() {
27+
local transcript_path="$1"
28+
local tool_use_id="$2"
29+
printf '%s/stop' "$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")"
30+
}
31+
32+
claude_preview_watch_fifo() {
33+
local transcript_path="$1"
34+
local tool_use_id="$2"
35+
printf '%s/transcript.fifo' "$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")"
36+
}
37+
38+
claude_preview_stop_transcript_watcher() {
39+
local transcript_path="$1"
40+
local tool_use_id="$2"
41+
local state_dir pidfile stopfile pid
42+
43+
state_dir="$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")"
44+
pidfile="$(claude_preview_watch_pidfile "$transcript_path" "$tool_use_id")"
45+
stopfile="$(claude_preview_watch_stopfile "$transcript_path" "$tool_use_id")"
46+
47+
if [[ ! -d "$state_dir" ]]; then
48+
return 0
49+
fi
50+
51+
: > "$stopfile"
52+
53+
if [[ -r "$pidfile" ]]; then
54+
pid="$(cat "$pidfile" 2>/dev/null || true)"
55+
if [[ "$pid" =~ ^[0-9]+$ ]]; then
56+
kill -TERM "$pid" 2>/dev/null || true
57+
fi
58+
fi
59+
}
60+
61+
claude_preview_line_is_rejection() {
62+
local line="$1"
63+
local tool_use_id="$2"
64+
65+
printf '%s\n' "$line" | jq -e --arg tool_use_id "$tool_use_id" '
66+
(.message.content // []) |
67+
any(.tool_use_id == $tool_use_id and (.is_error // false) == true)
68+
' >/dev/null 2>&1
69+
}
70+
71+
claude_preview_nvim_diff_is_open() {
72+
[[ -n "${NVIM_SOCKET:-}" ]] || return 1
73+
74+
local result
75+
result="$(
76+
nvim --server "$NVIM_SOCKET" --remote-expr \
77+
"luaeval(\"require('claude-preview.diff').is_open() and 1 or 0\")" \
78+
2>/dev/null
79+
)" || return 1
80+
81+
[[ "$result" == "1" ]]
82+
}
83+
84+
claude_preview_close_diff_for_rejection() {
85+
[[ -n "${NVIM_SOCKET:-}" ]] || return 1
86+
nvim_send "if require('claude-preview.diff').is_open() then pcall(function() require('claude-preview.changes').clear_all() end) pcall(function() require('claude-preview.diff').close_diff() end) end"
87+
}
88+
89+
claude_preview_watch_transcript() {
90+
local transcript_path="$1"
91+
local tool_use_id="$2"
92+
local state_dir pidfile stopfile fifo tail_pid deadline next_probe line
93+
94+
state_dir="$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")"
95+
pidfile="$(claude_preview_watch_pidfile "$transcript_path" "$tool_use_id")"
96+
stopfile="$(claude_preview_watch_stopfile "$transcript_path" "$tool_use_id")"
97+
fifo="$(claude_preview_watch_fifo "$transcript_path" "$tool_use_id")"
98+
99+
mkdir -p "$state_dir"
100+
rm -f "$pidfile" "$stopfile" "$fifo"
101+
102+
trap '
103+
if [[ -n "${tail_pid:-}" ]]; then
104+
kill "$tail_pid" 2>/dev/null || true
105+
wait "$tail_pid" 2>/dev/null || true
106+
fi
107+
rm -f "$pidfile" "$stopfile" "$fifo"
108+
rm -rf "$state_dir" 2>/dev/null || true
109+
' EXIT
110+
trap '' HUP
111+
112+
mkfifo "$fifo"
113+
tail -n0 -F "$transcript_path" >"$fifo" 2>/dev/null &
114+
tail_pid=$!
115+
exec 3<"$fifo"
116+
rm -f "$fifo"
117+
118+
deadline=$((SECONDS + 120))
119+
next_probe=$SECONDS
120+
121+
while (( SECONDS < deadline )); do
122+
if [[ -f "$stopfile" ]]; then
123+
break
124+
fi
125+
126+
if (( SECONDS >= next_probe )); then
127+
if ! claude_preview_nvim_diff_is_open; then
128+
break
129+
fi
130+
next_probe=$((SECONDS + 2))
131+
fi
132+
133+
if ! IFS= read -r -t 1 line <&3; then
134+
if ! kill -0 "$tail_pid" 2>/dev/null; then
135+
break
136+
fi
137+
continue
138+
fi
139+
140+
if claude_preview_line_is_rejection "$line" "$tool_use_id"; then
141+
rm -f "${TMPDIR:-/tmp}/claude-diff-original" "${TMPDIR:-/tmp}/claude-diff-proposed"
142+
claude_preview_close_diff_for_rejection || true
143+
break
144+
fi
145+
done
146+
}
147+
148+
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
149+
set -euo pipefail
150+
if [[ $# -lt 2 ]]; then
151+
echo "usage: $0 TRANSCRIPT_PATH TOOL_USE_ID" >&2
152+
exit 2
153+
fi
154+
source "$(dirname "$0")/nvim-send.sh"
155+
claude_preview_watch_transcript "$1" "$2"
156+
fi

bin/claude-user-prompt-cleanup.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
# claude-user-prompt-cleanup.sh — UserPromptSubmit hook for Claude Code
3+
# Belt-and-suspenders fallback: closes any orphaned diff preview tab
4+
# when the user sends their next message. Catches anything the
5+
# transcript watcher missed (e.g. watcher died, nvim socket changed).
6+
7+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8+
9+
INPUT="$(cat)"
10+
CWD="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"
11+
12+
source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
13+
source "$SCRIPT_DIR/nvim-send.sh"
14+
15+
if [[ -z "${NVIM_SOCKET:-}" ]]; then
16+
exit 0
17+
fi
18+
19+
# Fast path: single RPC to check if a diff is open. <100ms when nothing is open.
20+
DIFF_OPEN=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"require('claude-preview.diff').is_open() and 1 or 0\")" 2>/dev/null || echo "0")
21+
22+
if [[ "$DIFF_OPEN" == "1" ]]; then
23+
nvim_send "if require('claude-preview.diff').is_open() then pcall(function() require('claude-preview.changes').clear_all() end) pcall(function() require('claude-preview.diff').close_diff() end) end" || true
24+
rm -f "${TMPDIR:-/tmp}/claude-diff-original" "${TMPDIR:-/tmp}/claude-diff-proposed"
25+
fi
26+
27+
exit 0

0 commit comments

Comments
 (0)