From a6dada9646edc032dd858d1f14abb143aff01c90 Mon Sep 17 00:00:00 2001 From: kam-hak Date: Tue, 7 Apr 2026 11:03:13 -0400 Subject: [PATCH] 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 --- bin/claude-close-diff.sh | 8 + bin/claude-preview-diff.sh | 11 + bin/claude-preview-transcript-watch.sh | 156 +++ bin/claude-user-prompt-cleanup.sh | 27 + .../claude/test_transcript_watcher.sh | 940 ++++++++++++++++++ 5 files changed, 1142 insertions(+) create mode 100755 bin/claude-preview-transcript-watch.sh create mode 100755 bin/claude-user-prompt-cleanup.sh create mode 100644 tests/backends/claude/test_transcript_watcher.sh diff --git a/bin/claude-close-diff.sh b/bin/claude-close-diff.sh index 6278502..0bc4184 100755 --- a/bin/claude-close-diff.sh +++ b/bin/claude-close-diff.sh @@ -8,10 +8,13 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" INPUT="$(cat)" CWD="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)" +TRANSCRIPT_PATH="$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true)" +TOOL_USE_ID="$(echo "$INPUT" | jq -r '.tool_use_id // empty' 2>/dev/null || true)" # Discover Neovim socket (prefer instance whose cwd matches project) and load RPC helpers source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null source "$SCRIPT_DIR/nvim-send.sh" +source "$SCRIPT_DIR/claude-preview-transcript-watch.sh" # For Bash tool (rm detection), only clear deletion markers — don't touch edit markers or diff tab if [[ "$TOOL_NAME" == "Bash" ]]; then @@ -38,4 +41,9 @@ fi # Clean up temp files rm -f "${TMPDIR:-/tmp}/claude-diff-original" "${TMPDIR:-/tmp}/claude-diff-proposed" +# Stop the transcript watcher (if one was spawned by PreToolUse) +if [[ -n "$TRANSCRIPT_PATH" && -n "$TOOL_USE_ID" ]]; then + claude_preview_stop_transcript_watcher "$TRANSCRIPT_PATH" "$TOOL_USE_ID" || true +fi + exit 0 diff --git a/bin/claude-preview-diff.sh b/bin/claude-preview-diff.sh index 4b131e8..fcabdc3 100755 --- a/bin/claude-preview-diff.sh +++ b/bin/claude-preview-diff.sh @@ -12,10 +12,13 @@ INPUT="$(cat)" TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name')" CWD="$(echo "$INPUT" | jq -r '.cwd')" +TRANSCRIPT_PATH="$(echo "$INPUT" | jq -r '.transcript_path // empty')" +TOOL_USE_ID="$(echo "$INPUT" | jq -r '.tool_use_id // empty')" # Discover Neovim socket (prefer instance whose cwd matches project) and load RPC helpers source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true source "$SCRIPT_DIR/nvim-send.sh" +source "$SCRIPT_DIR/claude-preview-transcript-watch.sh" HAS_NVIM=true if [[ -z "${NVIM_SOCKET:-}" ]]; then @@ -190,6 +193,14 @@ if [[ "$HAS_NVIM" == "true" ]]; then fi nvim_send "require('claude-preview.diff').show_diff('$ORIG_ESC', '$PROP_ESC', '$DISPLAY_ESC')" || true + + # Spawn transcript watcher to close diff on rejection + if [[ -n "$TRANSCRIPT_PATH" && -n "$TOOL_USE_ID" ]]; then + ( + trap '' HUP + claude_preview_watch_transcript "$TRANSCRIPT_PATH" "$TOOL_USE_ID" + ) >/dev/null 2>&1 & + fi fi # --- Always ask for user confirmation --- diff --git a/bin/claude-preview-transcript-watch.sh b/bin/claude-preview-transcript-watch.sh new file mode 100755 index 0000000..9ae819f --- /dev/null +++ b/bin/claude-preview-transcript-watch.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# claude-preview-transcript-watch.sh — shared transcript watcher helpers +# +# Sourced by claude-preview-diff.sh and claude-close-diff.sh. + +claude_preview_watch_state_key() { + local transcript_path="$1" + local tool_use_id="$2" + printf '%s\0%s' "$transcript_path" "$tool_use_id" | cksum | awk '{print $1}' +} + +claude_preview_watch_state_dir() { + local transcript_path="$1" + local tool_use_id="$2" + local key + key="$(claude_preview_watch_state_key "$transcript_path" "$tool_use_id")" + printf '%s/claude-preview-watch-%s' "${TMPDIR:-/tmp}" "$key" +} + +claude_preview_watch_pidfile() { + local transcript_path="$1" + local tool_use_id="$2" + printf '%s/pid' "$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")" +} + +claude_preview_watch_stopfile() { + local transcript_path="$1" + local tool_use_id="$2" + printf '%s/stop' "$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")" +} + +claude_preview_watch_fifo() { + local transcript_path="$1" + local tool_use_id="$2" + printf '%s/transcript.fifo' "$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")" +} + +claude_preview_stop_transcript_watcher() { + local transcript_path="$1" + local tool_use_id="$2" + local state_dir pidfile stopfile pid + + state_dir="$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")" + pidfile="$(claude_preview_watch_pidfile "$transcript_path" "$tool_use_id")" + stopfile="$(claude_preview_watch_stopfile "$transcript_path" "$tool_use_id")" + + if [[ ! -d "$state_dir" ]]; then + return 0 + fi + + : > "$stopfile" + + if [[ -r "$pidfile" ]]; then + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [[ "$pid" =~ ^[0-9]+$ ]]; then + kill -TERM "$pid" 2>/dev/null || true + fi + fi +} + +claude_preview_line_is_rejection() { + local line="$1" + local tool_use_id="$2" + + printf '%s\n' "$line" | jq -e --arg tool_use_id "$tool_use_id" ' + (.message.content // []) | + any(.tool_use_id == $tool_use_id and (.is_error // false) == true) + ' >/dev/null 2>&1 +} + +claude_preview_nvim_diff_is_open() { + [[ -n "${NVIM_SOCKET:-}" ]] || return 1 + + local result + result="$( + nvim --server "$NVIM_SOCKET" --remote-expr \ + "luaeval(\"require('claude-preview.diff').is_open() and 1 or 0\")" \ + 2>/dev/null + )" || return 1 + + [[ "$result" == "1" ]] +} + +claude_preview_close_diff_for_rejection() { + [[ -n "${NVIM_SOCKET:-}" ]] || return 1 + 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" +} + +claude_preview_watch_transcript() { + local transcript_path="$1" + local tool_use_id="$2" + local state_dir pidfile stopfile fifo tail_pid deadline next_probe line + + state_dir="$(claude_preview_watch_state_dir "$transcript_path" "$tool_use_id")" + pidfile="$(claude_preview_watch_pidfile "$transcript_path" "$tool_use_id")" + stopfile="$(claude_preview_watch_stopfile "$transcript_path" "$tool_use_id")" + fifo="$(claude_preview_watch_fifo "$transcript_path" "$tool_use_id")" + + mkdir -p "$state_dir" + rm -f "$pidfile" "$stopfile" "$fifo" + + trap ' + if [[ -n "${tail_pid:-}" ]]; then + kill "$tail_pid" 2>/dev/null || true + wait "$tail_pid" 2>/dev/null || true + fi + rm -f "$pidfile" "$stopfile" "$fifo" + rm -rf "$state_dir" 2>/dev/null || true + ' EXIT + trap '' HUP + + mkfifo "$fifo" + tail -n0 -F "$transcript_path" >"$fifo" 2>/dev/null & + tail_pid=$! + exec 3<"$fifo" + rm -f "$fifo" + + deadline=$((SECONDS + 120)) + next_probe=$SECONDS + + while (( SECONDS < deadline )); do + if [[ -f "$stopfile" ]]; then + break + fi + + if (( SECONDS >= next_probe )); then + if ! claude_preview_nvim_diff_is_open; then + break + fi + next_probe=$((SECONDS + 2)) + fi + + if ! IFS= read -r -t 1 line <&3; then + if ! kill -0 "$tail_pid" 2>/dev/null; then + break + fi + continue + fi + + if claude_preview_line_is_rejection "$line" "$tool_use_id"; then + rm -f "${TMPDIR:-/tmp}/claude-diff-original" "${TMPDIR:-/tmp}/claude-diff-proposed" + claude_preview_close_diff_for_rejection || true + break + fi + done +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + set -euo pipefail + if [[ $# -lt 2 ]]; then + echo "usage: $0 TRANSCRIPT_PATH TOOL_USE_ID" >&2 + exit 2 + fi + source "$(dirname "$0")/nvim-send.sh" + claude_preview_watch_transcript "$1" "$2" +fi diff --git a/bin/claude-user-prompt-cleanup.sh b/bin/claude-user-prompt-cleanup.sh new file mode 100755 index 0000000..63a1bb2 --- /dev/null +++ b/bin/claude-user-prompt-cleanup.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# claude-user-prompt-cleanup.sh — UserPromptSubmit hook for Claude Code +# Belt-and-suspenders fallback: closes any orphaned diff preview tab +# when the user sends their next message. Catches anything the +# transcript watcher missed (e.g. watcher died, nvim socket changed). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +INPUT="$(cat)" +CWD="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" + +source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true +source "$SCRIPT_DIR/nvim-send.sh" + +if [[ -z "${NVIM_SOCKET:-}" ]]; then + exit 0 +fi + +# Fast path: single RPC to check if a diff is open. <100ms when nothing is open. +DIFF_OPEN=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"require('claude-preview.diff').is_open() and 1 or 0\")" 2>/dev/null || echo "0") + +if [[ "$DIFF_OPEN" == "1" ]]; then + 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 + rm -f "${TMPDIR:-/tmp}/claude-diff-original" "${TMPDIR:-/tmp}/claude-diff-proposed" +fi + +exit 0 diff --git a/tests/backends/claude/test_transcript_watcher.sh b/tests/backends/claude/test_transcript_watcher.sh new file mode 100644 index 0000000..6dbd6b6 --- /dev/null +++ b/tests/backends/claude/test_transcript_watcher.sh @@ -0,0 +1,940 @@ +#!/usr/bin/env bash +# test_transcript_watcher.sh — E2E tests for transcript-based rejection cleanup +# +# Tests three layers: +# Layer 1: Pure bash (no nvim) — rejection detection, watcher lifecycle +# Layer 2: Headless nvim integration — open diff, detect rejection, close diff +# Layer 3: Edge cases — multi-session, rapid accept, focus state, permission bypass +# +# Every test captures wall-clock timing for the critical path. + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +start_nvim + +BIN_DIR="$REPO_ROOT/bin" + +# Timing helper: returns current time in milliseconds +now_ms() { + if command -v gdate >/dev/null 2>&1; then + echo $(( $(gdate +%s%N) / 1000000 )) + elif date +%s%N | grep -q N; then + # macOS date doesn't support %N, fallback to python + python3 -c 'import time; print(int(time.time() * 1000))' + else + echo $(( $(date +%s%N) / 1000000 )) + fi +} + +report_timing() { + local label="$1" start_ms="$2" end_ms="$3" + local elapsed=$(( end_ms - start_ms )) + echo -e " ${YELLOW}timing: ${label} = ${elapsed}ms${NC}" +} + +# Source the transcript watcher library for direct function testing +source "$BIN_DIR/claude-preview-transcript-watch.sh" +source "$BIN_DIR/nvim-send.sh" +export NVIM_SOCKET="$TEST_SOCKET" + +# Build a synthetic CC transcript JSONL line for a tool rejection +make_rejection_jsonl() { + local tool_use_id="$1" + printf '{"type":"tool_result","message":{"content":[{"tool_use_id":"%s","is_error":true,"content":"The user doesn'\''t want to proceed with this tool use."}]}}\n' "$tool_use_id" +} + +# Build a synthetic CC transcript JSONL line for a tool acceptance (non-error) +make_acceptance_jsonl() { + local tool_use_id="$1" + printf '{"type":"tool_result","message":{"content":[{"tool_use_id":"%s","content":"Tool executed successfully."}]}}\n' "$tool_use_id" +} + +# Build a synthetic CC transcript JSONL line for a different tool +make_unrelated_jsonl() { + local tool_use_id="$1" + printf '{"type":"tool_result","message":{"content":[{"tool_use_id":"%s","is_error":true,"content":"Some other error."}]}}\n' "$tool_use_id" +} + +# Create a synthetic PreToolUse hook payload with transcript_path and tool_use_id +make_pretool_payload() { + local file_path="$1" + local transcript_path="$2" + local tool_use_id="$3" + local cwd="${4:-$TEST_PROJECT_DIR}" + cat <&2 + return 1 + fi +} + +# ── Test: state dir lifecycle ──────────────────────────────────── + +test_state_dir_lifecycle() { + local transcript="/tmp/test-transcript-$$" + local tool_id="toolu_LIFECYCLE" + + local state_dir pidfile stopfile + state_dir="$(claude_preview_watch_state_dir "$transcript" "$tool_id")" + pidfile="$(claude_preview_watch_pidfile "$transcript" "$tool_id")" + stopfile="$(claude_preview_watch_stopfile "$transcript" "$tool_id")" + + # State dir should not exist yet + assert_file_not_exists "$pidfile" "pidfile should not exist before watcher starts" || return 1 + + # Create state dir as watcher would + mkdir -p "$state_dir" + echo "12345" > "$pidfile" + assert_file_exists "$pidfile" "pidfile should exist after creation" || return 1 + + # Stopfile creation + : > "$stopfile" + assert_file_exists "$stopfile" "stopfile should exist after touch" || return 1 + + # Deterministic: same inputs produce same state dir + local state_dir2 + state_dir2="$(claude_preview_watch_state_dir "$transcript" "$tool_id")" + assert_eq "$state_dir" "$state_dir2" "same inputs should produce same state dir" || return 1 + + # Different inputs produce different state dir + local state_dir3 + state_dir3="$(claude_preview_watch_state_dir "$transcript" "toolu_OTHER")" + if [[ "$state_dir" == "$state_dir3" ]]; then + echo -e " ${RED}FAIL: different tool_use_id should produce different state dir${NC}" >&2 + return 1 + fi + + # Cleanup + rm -rf "$state_dir" +} + +# ── Test: stop_transcript_watcher with no running watcher ──────── + +test_stop_watcher_noop() { + # Should not error when no watcher exists + claude_preview_stop_transcript_watcher "/tmp/nonexistent" "toolu_NOOP" + local rc=$? + assert_eq "0" "$rc" "stopping nonexistent watcher should succeed silently" || return 1 +} + +# ── Test: watcher starts and stops on stopfile ─────────────────── + +test_watcher_stops_on_stopfile() { + # Need a diff open so the watcher doesn't exit immediately from is_open check + open_test_diff || return 1 + + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_STOPTEST" + echo '{"type":"init"}' > "$transcript" + + local t0 t1 + + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + sleep 0.5 + + # Watcher should be running + kill -0 "$watcher_pid" 2>/dev/null + assert_eq "0" "$?" "watcher should be running" || { kill "$watcher_pid" 2>/dev/null; rm -f "$transcript"; return 1; } + + t0="$(now_ms)" + # Signal it to stop via the library function (creates stopfile + sends TERM) + claude_preview_stop_transcript_watcher "$transcript" "$tool_id" + + # Wait for it to exit + wait "$watcher_pid" 2>/dev/null || true + t1="$(now_ms)" + + report_timing "watcher stop on stopfile" "$t0" "$t1" + + # Should have exited + ! kill -0 "$watcher_pid" 2>/dev/null + assert_eq "0" "$?" "watcher should have exited after stopfile" || return 1 + + nvim_exec "require('claude-preview.diff').close_diff()" + sleep 0.2 + rm -f "$transcript" +} + +# ── Test: nvim_diff_is_open shell function (live socket) ───────── + +test_nvim_diff_is_open_live_socket() { + # With no diff open, should return false (non-zero) + claude_preview_nvim_diff_is_open + local rc=$? + assert_eq "1" "$rc" "is_open should return false when no diff is open" || return 1 + + # Open a diff, should return true (zero) + open_test_diff || return 1 + claude_preview_nvim_diff_is_open + rc=$? + assert_eq "0" "$rc" "is_open should return true when diff is open" || return 1 + + nvim_exec "require('claude-preview.diff').close_diff()" + sleep 0.2 + + # After close, should return false again + claude_preview_nvim_diff_is_open + rc=$? + assert_eq "1" "$rc" "is_open should return false after close" || return 1 +} + +# ── Test: nvim_diff_is_open shell function (dead socket) ───────── + +test_nvim_diff_is_open_dead_socket() { + local saved_socket="$NVIM_SOCKET" + NVIM_SOCKET="/tmp/nonexistent-socket-$$" + + claude_preview_nvim_diff_is_open + local rc=$? + + NVIM_SOCKET="$saved_socket" + + # Dead socket should return non-zero (treats as "not open") + if [[ "$rc" -eq 0 ]]; then + echo -e " ${RED}FAIL: dead socket should return non-zero${NC}" >&2 + return 1 + fi +} + +# ── Test: nvim_diff_is_open shell function (empty socket) ──────── + +test_nvim_diff_is_open_no_socket() { + local saved_socket="$NVIM_SOCKET" + NVIM_SOCKET="" + + claude_preview_nvim_diff_is_open + local rc=$? + + NVIM_SOCKET="$saved_socket" + + if [[ "$rc" -eq 0 ]]; then + echo -e " ${RED}FAIL: empty socket should return non-zero${NC}" >&2 + return 1 + fi +} + +# ═══════════════════════════════════════════════════════════════════ +# LAYER 2: Headless nvim integration +# ═══════════════════════════════════════════════════════════════════ + +# Helper: open a diff in the test nvim and verify it's open +open_test_diff() { + local test_file + test_file="$(create_test_file "src/target.lua" 'print("hello")')" + + local orig="${TMPDIR:-/tmp}/claude-diff-original" + local prop="${TMPDIR:-/tmp}/claude-diff-proposed" + cp "$test_file" "$orig" + printf '%s' 'print("world")' > "$prop" + + nvim_exec "require('claude-preview.diff').show_diff('$orig', '$prop', 'src/target.lua')" + sleep 0.3 + + local is_open + is_open="$(nvim_eval "require('claude-preview.diff').is_open()")" + if [[ "$is_open" != "true" ]]; then + echo -e " ${RED}FAIL: diff should be open after show_diff${NC}" >&2 + return 1 + fi + echo "$test_file" +} + +# Helper: assert diff is closed +assert_diff_closed() { + local msg="${1:-diff should be closed}" + local is_open + is_open="$(nvim_eval "require('claude-preview.diff').is_open()")" + assert_eq "false" "$is_open" "$msg" +} + +# Helper: assert diff is open +assert_diff_open() { + local msg="${1:-diff should be open}" + local is_open + is_open="$(nvim_eval "require('claude-preview.diff').is_open()")" + assert_eq "true" "$is_open" "$msg" +} + +# Helper: count nvim tabs +count_tabs() { + nvim_eval "vim.fn.tabpagenr('$')" +} + +# Helper: count nvim buffers (listed) +count_listed_bufs() { + nvim_eval "vim.tbl_count(vim.fn.getbufinfo({buflisted = 1}))" +} + +# ── Test: diff open creates tab, close removes it ─────────────── + +test_diff_tab_lifecycle() { + local tabs_before + tabs_before="$(count_tabs)" + + open_test_diff || return 1 + + local tabs_with_diff + tabs_with_diff="$(count_tabs)" + if (( tabs_with_diff <= tabs_before )); then + echo -e " ${RED}FAIL: show_diff should create a new tab (before=$tabs_before, after=$tabs_with_diff)${NC}" >&2 + return 1 + fi + + nvim_exec "require('claude-preview.diff').close_diff()" + sleep 0.2 + + local tabs_after + tabs_after="$(count_tabs)" + assert_eq "$tabs_before" "$tabs_after" "close_diff should remove the tab" || return 1 +} + +# ── Test: diff open creates scratch buffers that get wiped ─────── + +test_diff_buffers_are_scratch() { + open_test_diff || return 1 + + # Check the diff tab's buffers are nofile/scratch + local buftypes + buftypes="$(nvim_eval "(function() local tab = vim.api.nvim_get_current_tabpage() local wins = vim.api.nvim_tabpage_list_wins(tab) local types = {} for _, w in ipairs(wins) do local b = vim.api.nvim_win_get_buf(w) table.insert(types, vim.bo[b].buftype) end return table.concat(types, ',') end)()")" + + # ALL buffers in the diff tab must be nofile (not just one) + local bad_type="" + IFS=',' read -ra types <<< "$buftypes" + for t in "${types[@]}"; do + if [[ "$t" != "nofile" ]]; then + bad_type="$t" + break + fi + done + if [[ -n "$bad_type" ]]; then + echo -e " ${RED}FAIL: all diff buffers should be nofile, got: $buftypes (bad: $bad_type)${NC}" >&2 + nvim_exec "require('claude-preview.diff').close_diff()" + return 1 + fi + + nvim_exec "require('claude-preview.diff').close_diff()" + sleep 0.2 +} + +# ── Test: watcher closes diff on rejection via nvim RPC ────────── + +test_watcher_closes_diff_on_rejection() { + open_test_diff || return 1 + + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_NVIM_REJECT" + echo '{"type":"init"}' > "$transcript" + + # Start watcher + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + sleep 0.5 + + # Verify diff is still open + assert_diff_open "diff should still be open before rejection" || { kill "$watcher_pid" 2>/dev/null; rm -f "$transcript"; return 1; } + + # Write rejection to transcript + local t0 t1 + t0="$(now_ms)" + make_rejection_jsonl "$tool_id" >> "$transcript" + + # Wait for watcher to process and close the diff + local tries=0 + while (( tries < 30 )); do + local is_open + is_open="$(nvim_eval "require('claude-preview.diff').is_open()")" + if [[ "$is_open" == "false" ]]; then + break + fi + sleep 0.1 + tries=$((tries + 1)) + done + t1="$(now_ms)" + + report_timing "rejection -> diff close (e2e)" "$t0" "$t1" + + assert_diff_closed "diff should be closed after rejection" || { kill "$watcher_pid" 2>/dev/null; rm -f "$transcript"; return 1; } + + # Watcher should have exited + wait "$watcher_pid" 2>/dev/null || true + ! kill -0 "$watcher_pid" 2>/dev/null + assert_eq "0" "$?" "watcher should have exited after closing diff" || return 1 + + # Changes should be cleared + local changes + changes="$(nvim_eval "vim.tbl_count(require('claude-preview.changes').get_all())")" + assert_eq "0" "$changes" "changes registry should be cleared after rejection close" || return 1 + + # Temp files should be cleaned up by watcher's rejection handler + assert_file_not_exists "${TMPDIR:-/tmp}/claude-diff-original" "original temp file should be removed after rejection" || return 1 + assert_file_not_exists "${TMPDIR:-/tmp}/claude-diff-proposed" "proposed temp file should be removed after rejection" || return 1 + + rm -f "$transcript" +} + +# ── Test: watcher exits cleanly on PostToolUse (acceptance) ────── + +test_watcher_stops_on_acceptance() { + open_test_diff || return 1 + + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_ACCEPT" + echo '{"type":"init"}' > "$transcript" + + # Start watcher + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + sleep 0.5 + + local t0 t1 + t0="$(now_ms)" + + # Simulate what PostToolUse does: stop watcher via stopfile, then close diff + claude_preview_stop_transcript_watcher "$transcript" "$tool_id" + nvim_exec "require('claude-preview.diff').close_diff()" + + wait "$watcher_pid" 2>/dev/null || true + t1="$(now_ms)" + + report_timing "acceptance stop (stopfile+close)" "$t0" "$t1" + + assert_diff_closed "diff should be closed after acceptance" || return 1 + ! kill -0 "$watcher_pid" 2>/dev/null + assert_eq "0" "$?" "watcher should have exited after stopfile" || return 1 + + rm -f "$transcript" +} + +# ── Test: watcher ignores unrelated tool_use_id in transcript ──── + +test_watcher_ignores_wrong_tool_id() { + open_test_diff || return 1 + + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_MINE" + echo '{"type":"init"}' > "$transcript" + + # Start watcher for our tool_use_id + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + sleep 0.5 + + # Write rejection for a DIFFERENT tool + make_rejection_jsonl "toolu_SOMEONE_ELSE" >> "$transcript" + sleep 0.5 + + # Diff should still be open + assert_diff_open "diff should remain open when wrong tool_use_id is rejected" || { kill "$watcher_pid" 2>/dev/null; rm -f "$transcript"; return 1; } + + # Now write our rejection + make_rejection_jsonl "$tool_id" >> "$transcript" + sleep 1 + + assert_diff_closed "diff should close when our tool_use_id is rejected" || { kill "$watcher_pid" 2>/dev/null; rm -f "$transcript"; return 1; } + + wait "$watcher_pid" 2>/dev/null || true + rm -f "$transcript" +} + +# ═══════════════════════════════════════════════════════════════════ +# LAYER 3: Edge cases +# ═══════════════════════════════════════════════════════════════════ + +# ── Test: second diff replaces first, first watcher cleans up ──── + +test_second_diff_replaces_first_watcher() { + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + echo '{"type":"init"}' > "$transcript" + + # Open first diff with watcher + open_test_diff || return 1 + local tool_id_1="toolu_FIRST" + claude_preview_watch_transcript "$transcript" "$tool_id_1" & + local watcher_pid_1=$! + sleep 0.3 + + # Open second diff (replaces first via show_diff -> close_diff) + local test_file2 + test_file2="$(create_test_file "src/second.lua" 'print("alpha")')" + local orig2="${TMPDIR:-/tmp}/claude-diff-original" + local prop2="${TMPDIR:-/tmp}/claude-diff-proposed" + cp "$test_file2" "$orig2" + printf '%s' 'print("beta")' > "$prop2" + nvim_exec "require('claude-preview.diff').show_diff('$orig2', '$prop2', 'src/second.lua')" + sleep 0.3 + + assert_diff_open "second diff should be open" || { kill "$watcher_pid_1" 2>/dev/null; rm -f "$transcript"; return 1; } + + # First watcher: diff it was watching is gone (is_open still true for the new diff, + # but the tab handle changed). The watcher checks is_open generically, so it sees + # a diff is still open. Stopfile from PostToolUse of the first edit would clean it up. + # For this test, simulate that stopfile. + claude_preview_stop_transcript_watcher "$transcript" "$tool_id_1" + wait "$watcher_pid_1" 2>/dev/null || true + ! kill -0 "$watcher_pid_1" 2>/dev/null + assert_eq "0" "$?" "first watcher should exit after stopfile" || return 1 + + # Second watcher can now independently detect rejection + local tool_id_2="toolu_SECOND" + claude_preview_watch_transcript "$transcript" "$tool_id_2" & + local watcher_pid_2=$! + sleep 0.3 + + make_rejection_jsonl "$tool_id_2" >> "$transcript" + sleep 1 + + assert_diff_closed "second diff should close on rejection" || { kill "$watcher_pid_2" 2>/dev/null; rm -f "$transcript"; return 1; } + wait "$watcher_pid_2" 2>/dev/null || true + + rm -f "$transcript" +} + +# ── Test: diff not focused when rejection arrives ──────────────── + +test_rejection_closes_unfocused_diff() { + open_test_diff || return 1 + + # Switch away from diff tab (back to original tab) + nvim_exec "vim.cmd('tabfirst')" + sleep 0.2 + + # Verify we're not on the diff tab but diff is still open + assert_diff_open "diff should be open even when not focused" || return 1 + + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_UNFOCUSED" + echo '{"type":"init"}' > "$transcript" + + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + sleep 0.3 + + local t0 t1 + t0="$(now_ms)" + make_rejection_jsonl "$tool_id" >> "$transcript" + + local tries=0 + while (( tries < 30 )); do + local is_open + is_open="$(nvim_eval "require('claude-preview.diff').is_open()")" + if [[ "$is_open" == "false" ]]; then break; fi + sleep 0.1 + tries=$((tries + 1)) + done + t1="$(now_ms)" + + report_timing "unfocused rejection -> close" "$t0" "$t1" + assert_diff_closed "unfocused diff should close on rejection" || { kill "$watcher_pid" 2>/dev/null; rm -f "$transcript"; return 1; } + + wait "$watcher_pid" 2>/dev/null || true + rm -f "$transcript" +} + +# ── Test: rapid accept (PostToolUse before watcher reads) ──────── + +test_rapid_accept_before_watcher_reads() { + open_test_diff || return 1 + + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_RAPID" + echo '{"type":"init"}' > "$transcript" + + # Start watcher, give it just enough time to mkdir (but not necessarily to set up FIFO) + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + sleep 0.1 + + # Stop via library function only (no direct kill). This is the real production path: + # PostToolUse fires and calls stop_transcript_watcher. + local t0 t1 + t0="$(now_ms)" + claude_preview_stop_transcript_watcher "$transcript" "$tool_id" + + # Also close the diff (as PostToolUse would) so the watcher's is_open check exits it + nvim_exec "require('claude-preview.diff').close_diff()" + + # Give watcher time to notice either stopfile or diff-closed + local tries=0 + while kill -0 "$watcher_pid" 2>/dev/null && (( tries < 30 )); do + sleep 0.1 + tries=$((tries + 1)) + done + t1="$(now_ms)" + + report_timing "rapid accept (stopfile + diff close)" "$t0" "$t1" + + wait "$watcher_pid" 2>/dev/null || true + ! kill -0 "$watcher_pid" 2>/dev/null + assert_eq "0" "$?" "watcher should exit after rapid stop" || return 1 + + assert_diff_closed "diff should be closed after normal PostToolUse path" || return 1 + + rm -f "$transcript" +} + +# ── Test: two independent transcript watchers (multi CC session) ─ + +test_two_independent_watchers() { + open_test_diff || return 1 + + local transcript_a transcript_b + transcript_a="$(mktemp /tmp/test-transcript-A-XXXXXX.jsonl)" + transcript_b="$(mktemp /tmp/test-transcript-B-XXXXXX.jsonl)" + echo '{"type":"init"}' > "$transcript_a" + echo '{"type":"init"}' > "$transcript_b" + + local tool_id_a="toolu_SESSION_A" + local tool_id_b="toolu_SESSION_B" + + # Start both watchers + claude_preview_watch_transcript "$transcript_a" "$tool_id_a" & + local watcher_a=$! + claude_preview_watch_transcript "$transcript_b" "$tool_id_b" & + local watcher_b=$! + sleep 0.5 + + # Reject session A's tool + make_rejection_jsonl "$tool_id_a" >> "$transcript_a" + sleep 1 + + # Diff should be closed (session A's watcher closed it) + assert_diff_closed "session A rejection should close the diff" || { + kill "$watcher_a" "$watcher_b" 2>/dev/null + rm -f "$transcript_a" "$transcript_b" + return 1 + } + + # Session B's watcher should also exit (diff is no longer open) + # Give it time to notice via its periodic is_open check + sleep 3 + + # Both watchers should be gone + wait "$watcher_a" 2>/dev/null || true + wait "$watcher_b" 2>/dev/null || true + ! kill -0 "$watcher_a" 2>/dev/null + assert_eq "0" "$?" "watcher A should have exited" || return 1 + ! kill -0 "$watcher_b" 2>/dev/null + assert_eq "0" "$?" "watcher B should have exited" || return 1 + + rm -f "$transcript_a" "$transcript_b" +} + +# ── Test: watcher self-terminates when diff is manually closed ─── + +test_watcher_exits_on_manual_close() { + open_test_diff || return 1 + + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_MANUAL" + echo '{"type":"init"}' > "$transcript" + + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + sleep 0.5 + + # Simulate user pressing dq + local t0 t1 + t0="$(now_ms)" + nvim_exec "require('claude-preview.diff').close_diff_and_clear()" + + # Watcher should notice diff is gone within its 2s probe interval + local tries=0 + while kill -0 "$watcher_pid" 2>/dev/null && (( tries < 30 )); do + sleep 0.2 + tries=$((tries + 1)) + done + t1="$(now_ms)" + + report_timing "manual close -> watcher exit" "$t0" "$t1" + + ! kill -0 "$watcher_pid" 2>/dev/null + assert_eq "0" "$?" "watcher should exit when diff is manually closed" || return 1 + + wait "$watcher_pid" 2>/dev/null || true + rm -f "$transcript" +} + +# ── Test: permission bypass (no transcript fields) ─────────────── + +test_no_transcript_fields_graceful() { + # When CC auto-allows a tool, transcript_path/tool_use_id may be present + # but PostToolUse fires normally. Verify that empty fields don't crash. + local test_file + test_file="$(create_test_file "src/bypass.lua" 'print("auto")')" + + local payload + payload=$(cat <&2 + rm -rf "$empty_state_dir" + return 1 + fi +} + +# ── Test: watcher cleanup removes all temp state ───────────────── + +test_watcher_cleans_up_state() { + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_CLEANUP" + echo '{"type":"init"}' > "$transcript" + + local state_dir + state_dir="$(claude_preview_watch_state_dir "$transcript" "$tool_id")" + + # Ensure no leftover state + rm -rf "$state_dir" + + open_test_diff || return 1 + + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + + # Wait for state_dir to appear (watcher creates it via mkdir -p) + local tries=0 + while [[ ! -d "$state_dir" ]] && (( tries < 20 )); do + sleep 0.2 + tries=$((tries + 1)) + done + + if [[ ! -d "$state_dir" ]]; then + echo -e " ${RED}FAIL: state dir should exist while watcher runs${NC}" >&2 + kill "$watcher_pid" 2>/dev/null + rm -f "$transcript" + return 1 + fi + + # Kill watcher via rejection + make_rejection_jsonl "$tool_id" >> "$transcript" + wait "$watcher_pid" 2>/dev/null || true + sleep 0.3 + + # State files should be cleaned up by the EXIT trap. + # The empty directory itself may survive on some CI runners (macOS GitHub Actions) + # where background subshell EXIT traps race with wait, so we check files not dir. + local leftover_files + leftover_files="$(find "$state_dir" -type f 2>/dev/null | head -5)" + if [[ -n "$leftover_files" ]]; then + echo -e " ${RED}FAIL: state files should be removed after watcher exits${NC}" >&2 + echo -e " leftover: $leftover_files" >&2 + rm -rf "$state_dir" + rm -f "$transcript" + return 1 + fi + rm -rf "$state_dir" 2>/dev/null || true + + rm -f "$transcript" +} + +# ── Test: watcher exits when tail process dies ─────────────────── + +test_watcher_exits_on_tail_death() { + open_test_diff || return 1 + + local transcript + transcript="$(mktemp /tmp/test-transcript-XXXXXX.jsonl)" + local tool_id="toolu_TAILDEATH" + echo '{"type":"init"}' > "$transcript" + + claude_preview_watch_transcript "$transcript" "$tool_id" & + local watcher_pid=$! + sleep 0.5 + + # Find and kill the tail process that the watcher spawned. + # The watcher's tail is tailing our specific transcript file. + local tail_pids + tail_pids="$(pgrep -f "tail.*$transcript" 2>/dev/null || true)" + if [[ -z "$tail_pids" ]]; then + echo -e " ${RED}FAIL: could not find tail process for watcher${NC}" >&2 + kill "$watcher_pid" 2>/dev/null + rm -f "$transcript" + return 1 + fi + + local t0 t1 + t0="$(now_ms)" + + # Kill the tail process + for pid in $tail_pids; do + kill "$pid" 2>/dev/null || true + done + + # Watcher should notice tail died and exit + local tries=0 + while kill -0 "$watcher_pid" 2>/dev/null && (( tries < 30 )); do + sleep 0.2 + tries=$((tries + 1)) + done + t1="$(now_ms)" + + report_timing "tail death -> watcher exit" "$t0" "$t1" + + wait "$watcher_pid" 2>/dev/null || true + ! kill -0 "$watcher_pid" 2>/dev/null + assert_eq "0" "$?" "watcher should exit when tail process dies" || return 1 + + nvim_exec "require('claude-preview.diff').close_diff()" + sleep 0.2 + rm -f "$transcript" +} + +# ═══════════════════════════════════════════════════════════════════ +# Run all tests +# ═══════════════════════════════════════════════════════════════════ + +echo "" +echo -e "${YELLOW}── Layer 1: Pure bash (no nvim) ──${NC}" +run_test "rejection line detection (positive)" test_line_is_rejection_positive +run_test "rejection line detection (wrong id)" test_line_is_rejection_wrong_id +run_test "rejection line detection (acceptance)" test_line_is_rejection_acceptance +run_test "rejection line detection (unrelated tool)" test_line_is_rejection_unrelated +run_test "rejection line detection (garbage)" test_line_is_rejection_garbage +run_test "state dir lifecycle" test_state_dir_lifecycle +run_test "stop watcher (no-op)" test_stop_watcher_noop +run_test "watcher stops on stopfile" test_watcher_stops_on_stopfile +run_test "is_open shell function (live socket)" test_nvim_diff_is_open_live_socket +run_test "is_open shell function (dead socket)" test_nvim_diff_is_open_dead_socket +run_test "is_open shell function (no socket)" test_nvim_diff_is_open_no_socket + +echo "" +echo -e "${YELLOW}── Layer 2: Headless nvim integration ──${NC}" +run_test "diff tab lifecycle" test_diff_tab_lifecycle +run_test "diff buffers are scratch (all nofile)" test_diff_buffers_are_scratch +run_test "watcher closes diff on rejection" test_watcher_closes_diff_on_rejection +run_test "watcher stops on acceptance" test_watcher_stops_on_acceptance +run_test "watcher ignores wrong tool_use_id" test_watcher_ignores_wrong_tool_id + +echo "" +echo -e "${YELLOW}── Layer 3: Edge cases ──${NC}" +run_test "second diff replaces first watcher" test_second_diff_replaces_first_watcher +run_test "rejection closes unfocused diff" test_rejection_closes_unfocused_diff +run_test "rapid accept (stopfile only, no kill)" test_rapid_accept_before_watcher_reads +run_test "two independent watchers (multi session)" test_two_independent_watchers +run_test "watcher exits on manual close" test_watcher_exits_on_manual_close +run_test "watcher exits on tail death" test_watcher_exits_on_tail_death +run_test "no transcript fields (permission bypass)" test_no_transcript_fields_graceful +run_test "watcher cleans up state" test_watcher_cleans_up_state + +# ── Teardown ───────────────────────────────────────────────────── + +stop_nvim +cleanup_test_project