From 864f967766159632b3a882d1c7528647b9cf6070 Mon Sep 17 00:00:00 2001 From: Elle Najt Date: Wed, 23 Jul 2025 01:27:57 -0400 Subject: [PATCH 1/3] Add Claude Code hooks integration support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds comprehensive hook integration between Claude Code CLI and Emacs: - Add claude-code-hook variable and claude-code-handle-hook function to receive hook events from CLI - Set CLAUDE_BUFFER_NAME environment variable in terminal processes for hook context - Pass through JSON data from Claude Code CLI stdin to Emacs hook handlers via temporary files - Update README with detailed hooks documentation and configuration examples - Add examples/hooks/ directory with hook handler examples and CLI settings template - Support for notification, stop, pre-tool-use, and post-tool-use hook types 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 94 ++++++++++++ claude-code.el | 22 ++- examples/hooks/claude-code-hook-examples.el | 161 ++++++++++++++++++++ examples/hooks/example_settings.json | 52 +++++++ 4 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 examples/hooks/claude-code-hook-examples.el create mode 100644 examples/hooks/example_settings.json diff --git a/README.md b/README.md index d47284f..45bbee4 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,100 @@ For Windows, you can use PowerShell to create toast notifications: *Note: Linux and Windows examples are untested. Feedback and improvements are welcome!* +### Claude Code Hooks Integration + +claude-code.el provides integration to **receive** hook events from Claude Code CLI via emacsclient. This handler expects to recieve the buffer name and the location of a temporary file that stores the JSON that Claude Code passes to the hook via stdin. + +See ./examples/hooks for some examples. + +#### Hook Handler + +- `claude-code-hook` - Emacs hook run when Claude Code CLI triggers hooks +- `claude-code-handle-hook` - Main function that receives hook events from emacsclient. Parameters must be passed in this exact order: `(type buffer-name json-tmpfile &rest args)` + +#### Setup + +```elisp +;; Add your hook handlers using standard Emacs functions +(add-hook 'claude-code-hook 'my-claude-hook-handler) +``` + +#### Custom Hook Handler + +```elisp +;; Define your own hook handler function +(defun my-claude-hook-handler (message) + "Custom handler for Claude Code hooks. +MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." + (let ((hook-type (plist-get message :type)) + (buffer-name (plist-get message :buffer-name)) + (json-data (plist-get message :json-data)) + (args (plist-get message :args))) + (cond + ((eq hook-type 'notification) + (message "Claude is ready in %s! JSON: %s" buffer-name json-data) + ;; Add your notification logic here + ) + ((eq hook-type 'stop) + (message "Claude finished in %s! JSON: %s" buffer-name json-data) + ;; Add your cleanup logic here + ) + ;; Handle other hook types: 'pre-tool-use', 'post-tool-use', etc. + (t + (message "Claude hook: %s with JSON: %s" hook-type json-data))))) + +;; Add the hook handler using standard Emacs hook functions +(add-hook 'claude-code-hook 'my-claude-hook-handler) + +;; Or add multiple handlers +(add-hook 'claude-code-hook 'my-other-hook-handler) +(add-hook 'claude-code-hook 'my-third-hook-handler) + +;; Remove a handler if needed +(remove-hook 'claude-code-hook 'my-claude-hook-handler) +``` + +#### Claude Code CLI Configuration + +Configure Claude Code CLI hooks to call `claude-code-handle-hook` via emacsclient using temporary files for JSON data: + +```json +{ + "hooks": { + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + } + ] + } + ] + } +} +``` + +The command pattern: +1. `tmpfile=$(mktemp)` - Create temporary file +2. `cat > "$tmpfile"` - Write JSON from stdin to temp file +3. `emacsclient --eval "..."` - Call `claude-code-handle-hook` with parameters in order: hook-type, buffer-name, temp-file-path +4. `rm "$tmpfile"` - Clean up temp file + +See the [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/claude-code/hooks) for details on setting up CLI hooks. + ## Tips and Tricks - **Paste images**: Use `C-v` to paste images into the Claude window. Note that on macOS, this is `Control-v`, not `Command-v`. diff --git a/claude-code.el b/claude-code.el index 9f892bf..4899ac2 100644 --- a/claude-code.el +++ b/claude-code.el @@ -49,6 +49,11 @@ :type 'hook :group 'claude-code) +(defvar claude-code-hook nil + "Hook run when Claude Code CLI triggers hooks. +Functions in this hook are called with one argument: a plist with :type and +:buffer-name keys. Use `add-hook' and `remove-hook' to manage this hook.") + (defcustom claude-code-startup-delay 0.1 "Delay in seconds after starting Claude before displaying buffer. @@ -485,7 +490,9 @@ PROGRAM is the program to run in the terminal. SWITCHES are optional command-line arguments for PROGRAM." (claude-code--ensure-eat) - (let* ((trimmed-buffer-name (string-trim-right (string-trim buffer-name "\\*") "\\*"))) + (let* ((trimmed-buffer-name (string-trim-right (string-trim buffer-name "\\*") "\\*")) + (process-environment (cons (format "CLAUDE_BUFFER_NAME=%s" buffer-name) + process-environment))) (apply #'eat-make trimmed-buffer-name program nil switches))) (cl-defmethod claude-code--term-send-string ((_backend (eql eat)) string) @@ -674,6 +681,8 @@ SWITCHES are optional command-line arguments for PROGRAM." (let* ((vterm-shell (if switches (concat program " " (mapconcat #'identity switches " ")) program)) + (vterm-environment (cons (format "CLAUDE_BUFFER_NAME=%s" buffer-name) + vterm-environment)) (buffer (get-buffer-create buffer-name))) (with-current-buffer buffer ;; vterm needs to have an open window before starting the claude @@ -1359,6 +1368,17 @@ MESSAGE is the notification body." (claude-code--pulse-modeline) (message "%s: %s" title message)) + +(defun claude-code-handle-hook (type buffer-name json-tmpfile &rest args) + "Handle hook of TYPE for BUFFER-NAME with JSON data from JSON-TMPFILE. +Additional ARGS can be passed for extensibility." + (when (file-exists-p json-tmpfile) + (let ((json-data (with-temp-buffer + (insert-file-contents json-tmpfile) + (buffer-string)))) + (let ((message (list :type type :buffer-name buffer-name :json-data json-data :args args))) + (run-hook-with-args 'claude-code-hook message))))) + (defun claude-code--notify (_terminal) "Notify the user that Claude has finished and is awaiting input. diff --git a/examples/hooks/claude-code-hook-examples.el b/examples/hooks/claude-code-hook-examples.el new file mode 100644 index 0000000..6db61f4 --- /dev/null +++ b/examples/hooks/claude-code-hook-examples.el @@ -0,0 +1,161 @@ +;;; claude-code-hook-examples.el --- Example hook handlers for Claude Code -*- lexical-binding: t; -*- + +;; Author: Example +;; Version: 0.1.0 +;; Package-Requires: ((emacs "30.0") (claude-code "0.2.0")) +;; Keywords: tools, ai + +;;; Commentary: +;; This file provides examples of how to configure and use Claude Code hooks. +;; Copy and adapt these examples to your own configuration. + +;;; Code: + +;;;; Example Hook Handlers + +;; Uses the new hook API where claude-code-handle-hook creates a plist message +;; with :type, :buffer-name, :json-data, and :args keys + +(defun my-claude-notification-handler (message) + "Handle Claude notification hooks with visual and audio feedback. +MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." + (let ((hook-type (plist-get message :type)) + (buffer-name (plist-get message :buffer-name)) + (json-data (plist-get message :json-data)) + (args (plist-get message :args))) + (cond + ((eq hook-type 'notification) + ;; Visual notification + (message "🤖 Claude is ready for input in %s! JSON: %s" buffer-name json-data) + ;; Audio notification + (ding) + ;; Optional: switch to Claude buffer + (when buffer-name + (let ((claude-buffer (get-buffer buffer-name))) + (when claude-buffer + (display-buffer claude-buffer))))) + ((eq hook-type 'stop) + ;; Notification when Claude finishes + (message "✅ Claude finished responding in %s! JSON: %s" buffer-name json-data) + (ding))))) + +(defun my-claude-tool-use-tracker (message) + "Track Claude's tool usage for debugging/monitoring. +MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." + (let ((hook-type (plist-get message :type)) + (buffer-name (plist-get message :buffer-name)) + (json-data (plist-get message :json-data))) + (cond + ((eq hook-type 'pre-tool-use) + (message "🔧 Claude is about to use a tool in %s. JSON: %s" buffer-name json-data)) + ((eq hook-type 'post-tool-use) + (message "✅ Claude finished using a tool in %s. JSON: %s" buffer-name json-data))))) + +(defun my-claude-session-logger (message) + "Log all Claude hook events to a file. +MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." + (let ((hook-type (plist-get message :type)) + (buffer-name (plist-get message :buffer-name)) + (json-data (plist-get message :json-data)) + (timestamp (format-time-string "%Y-%m-%d %H:%M:%S"))) + (with-temp-buffer + (insert (format "[%s] %s: %s (JSON: %s)\n" timestamp hook-type buffer-name json-data)) + (append-to-file (point-min) (point-max) "~/claude-hooks.log")))) + +(defun my-claude-org-task-manager (message) + "Create org-mode entries for Claude sessions. +MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." + (let ((hook-type (plist-get message :type)) + (buffer-name (plist-get message :buffer-name)) + (json-data (plist-get message :json-data))) + (when (eq hook-type 'notification) + ;; Create an org entry when Claude is ready + (let ((org-file "~/claude-tasks-example.org") + (task-title (format "Claude session in %s" buffer-name))) + (when (file-exists-p org-file) + (with-temp-buffer + (insert (format "* TODO %s\n SCHEDULED: <%s>\n :PROPERTIES:\n :CLAUDE_BUFFER: %s\n :CLAUDE_JSON: %s\n :END:\n\n" + task-title + (format-time-string "%Y-%m-%d %a %H:%M") + buffer-name + json-data)) + (append-to-file (point-min) (point-max) org-file))))))) + +;;;; Hook Setup Examples + +(defun setup-claude-hooks-basic () + "Set up basic Claude hook handling with notifications." + (interactive) + (add-hook 'claude-code-hook 'my-claude-notification-handler) + (message "Basic Claude hooks configured")) + +(defun setup-claude-hooks-advanced () + "Set up advanced Claude hook handling with multiple handlers." + (interactive) + ;; Add multiple handlers + (add-hook 'claude-code-hook 'my-claude-notification-handler) + (add-hook 'claude-code-hook 'my-claude-tool-use-tracker) + (add-hook 'claude-code-hook 'my-claude-session-logger) + (message "Advanced Claude hooks configured")) + +(defun setup-claude-hooks-org-integration () + "Set up Claude hooks with org-mode integration." + (interactive) + (add-hook 'claude-code-hook 'my-claude-notification-handler) + (add-hook 'claude-code-hook 'my-claude-org-task-manager) + (message "Claude hooks with org-mode integration configured")) + +;;;; Utility Functions + +(defun remove-all-claude-hooks () + "Remove all Claude hook handlers." + (interactive) + (setq claude-code-hook nil) + (message "All Claude hooks removed")) + +(defun list-claude-hooks () + "Show currently configured Claude hook handlers." + (interactive) + (if claude-code-hook + (message "Claude hooks: %s" + (mapconcat (lambda (f) (symbol-name f)) claude-code-hook ", ")) + (message "No Claude hooks configured"))) + +;;;; Usage Instructions +;; +;; To use these examples: +;; +;; 1. Load this file: (load-file "claude-code-hook-examples.el") +;; 2. Set up hooks: (setup-claude-hooks-basic) ; or one of the other setup functions +;; 3. Configure Claude Code CLI hooks in .claude/settings.json: +;; +;; { +;; "hooks": { +;; "Notification": [ +;; { +;; "matcher": "", +;; "hooks": [ +;; { +;; "type": "command", +;; "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" +;; } +;; ] +;; } +;; ], +;; "Stop": [ +;; { +;; "matcher": "", +;; "hooks": [ +;; { +;; "type": "command", +;; "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" +;; } +;; ] +;; } +;; ] +;; } +;; } + +(provide 'claude-code-hook-examples) + +;;; claude-code-hook-examples.el ends here diff --git a/examples/hooks/example_settings.json b/examples/hooks/example_settings.json new file mode 100644 index 0000000..5204d77 --- /dev/null +++ b/examples/hooks/example_settings.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [], + "deny": [] + }, + "hooks": { + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'pre-tool-use \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'post-tool-use \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + } + ] + } + ] + } +} From 8e79be3710faa39d391f70ee4d8db7703a3ad179 Mon Sep 17 00:00:00 2001 From: Elle Najt Date: Thu, 24 Jul 2025 15:36:55 -0400 Subject: [PATCH 2/3] Address PR feedback: remove temp files, rename hook, improve docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace temp file approach with direct JSON passing via emacsclient args - Rename claude-code-hook to claude-code-event-hook for clarity - Remove /opt/homebrew/bin prefix from examples for broader compatibility - Update all hook configurations to use new streamlined approach - Add examples link to README and improve documentation clarity - Update hook handler to use server-eval-args-left for JSON data 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 84 +++++++------ claude-code.el | 40 +++--- examples/hooks/claude-code-hook-examples.el | 127 +++++++++++++++++--- examples/hooks/example_settings.json | 8 +- 4 files changed, 183 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 45bbee4..148ab5f 100644 --- a/README.md +++ b/README.md @@ -292,28 +292,39 @@ For Windows, you can use PowerShell to create toast notifications: ### Claude Code Hooks Integration -claude-code.el provides integration to **receive** hook events from Claude Code CLI via emacsclient. This handler expects to recieve the buffer name and the location of a temporary file that stores the JSON that Claude Code passes to the hook via stdin. +claude-code.el provides integration to **receive** hook events from Claude Code CLI via emacsclient. -See ./examples/hooks for some examples. +See [`examples/hooks/claude-code-hook-examples.el`](examples/hooks/claude-code-hook-examples.el) for comprehensive examples of hook listeners and setup functions. -#### Hook Handler +#### Hook API -- `claude-code-hook` - Emacs hook run when Claude Code CLI triggers hooks -- `claude-code-handle-hook` - Main function that receives hook events from emacsclient. Parameters must be passed in this exact order: `(type buffer-name json-tmpfile &rest args)` +- `claude-code-event-hook` - Emacs hook run when Claude Code CLI triggers events +- `claude-code-handle-hook` - **Unified entry point** for all Claude Code CLI hooks. Call this from your CLI hooks with `(type buffer-name &rest args)` and JSON data as additional emacsclient arguments #### Setup +Before configuring hooks, you need to start the Emacs server so that `emacsclient` can communicate with your Emacs instance: + ```elisp -;; Add your hook handlers using standard Emacs functions -(add-hook 'claude-code-hook 'my-claude-hook-handler) +;; Start the Emacs server (add this to your init.el) +(start-server) + +;; Add your hook listeners using standard Emacs functions +(add-hook 'claude-code-event-hook 'my-claude-hook-listener) ``` -#### Custom Hook Handler +#### Custom Hook Listener + +Hook listeners receive a message plist with these keys: +- `:type` - Hook type (e.g., `'notification`, `'stop`, `'pre-tool-use`, `'post-tool-use`) +- `:buffer-name` - Claude buffer name from `$CLAUDE_BUFFER_NAME` +- `:json-data` - JSON payload from Claude CLI +- `:args` - List of additional arguments (when using extended configuration) ```elisp -;; Define your own hook handler function -(defun my-claude-hook-handler (message) - "Custom handler for Claude Code hooks. +;; Define your own hook listener function +(defun my-claude-hook-listener (message) + "Custom listener for Claude Code hooks. MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." (let ((hook-type (plist-get message :type)) (buffer-name (plist-get message :buffer-name)) @@ -321,31 +332,21 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." (args (plist-get message :args))) (cond ((eq hook-type 'notification) - (message "Claude is ready in %s! JSON: %s" buffer-name json-data) - ;; Add your notification logic here - ) + (message "Claude is ready in %s! JSON: %s" buffer-name json-data)) ((eq hook-type 'stop) - (message "Claude finished in %s! JSON: %s" buffer-name json-data) - ;; Add your cleanup logic here - ) - ;; Handle other hook types: 'pre-tool-use', 'post-tool-use', etc. + (message "Claude finished in %s! JSON: %s" buffer-name json-data)) (t (message "Claude hook: %s with JSON: %s" hook-type json-data))))) -;; Add the hook handler using standard Emacs hook functions -(add-hook 'claude-code-hook 'my-claude-hook-handler) - -;; Or add multiple handlers -(add-hook 'claude-code-hook 'my-other-hook-handler) -(add-hook 'claude-code-hook 'my-third-hook-handler) - -;; Remove a handler if needed -(remove-hook 'claude-code-hook 'my-claude-hook-handler) +;; Add the hook listener using standard Emacs hook functions +(add-hook 'claude-code-event-hook 'my-claude-hook-listener) ``` +See the examples file for complete listeners that demonstrate notifications, logging, org-mode integration, and using extra arguments from the `:args` field. + #### Claude Code CLI Configuration -Configure Claude Code CLI hooks to call `claude-code-handle-hook` via emacsclient using temporary files for JSON data: +Configure Claude Code CLI hooks to call `claude-code-handle-hook` via emacsclient by passing JSON data as an additional argument: ```json { @@ -356,7 +357,7 @@ Configure Claude Code CLI hooks to call `claude-code-handle-hook` via emacsclien "hooks": [ { "type": "command", - "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + "command": "emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" } ] } @@ -367,7 +368,7 @@ Configure Claude Code CLI hooks to call `claude-code-handle-hook` via emacsclien "hooks": [ { "type": "command", - "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + "command": "emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" } ] } @@ -376,11 +377,24 @@ Configure Claude Code CLI hooks to call `claude-code-handle-hook` via emacsclien } ``` -The command pattern: -1. `tmpfile=$(mktemp)` - Create temporary file -2. `cat > "$tmpfile"` - Write JSON from stdin to temp file -3. `emacsclient --eval "..."` - Call `claude-code-handle-hook` with parameters in order: hook-type, buffer-name, temp-file-path -4. `rm "$tmpfile"` - Clean up temp file +The command pattern: +```bash +emacsclient --eval "(claude-code-handle-hook 'notification \"$CLAUDE_BUFFER_NAME\")" "$(cat)" "ARG1" "ARG2" "ARG3" +``` + +Where: +- `"$(cat)"` - JSON data from stdin (always required) +- `ARG1` is `"$PWD"` - current working directory +- `ARG2` is `"$(date -Iseconds)"` - timestamp +- `ARG3` is `"$$"` - process ID + +`claude-code-handle-hook` creates a message plist sent to listeners: +```elisp +(list :type 'notification + :buffer-name "$CLAUDE_BUFFER_NAME" + :json-data "$(cat)" + :args '("ARG1" "ARG2" "ARG3")) +``` See the [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/claude-code/hooks) for details on setting up CLI hooks. diff --git a/claude-code.el b/claude-code.el index 4899ac2..542622d 100644 --- a/claude-code.el +++ b/claude-code.el @@ -49,8 +49,8 @@ :type 'hook :group 'claude-code) -(defvar claude-code-hook nil - "Hook run when Claude Code CLI triggers hooks. +(defvar claude-code-event-hook nil + "Hook run when Claude Code CLI triggers events. Functions in this hook are called with one argument: a plist with :type and :buffer-name keys. Use `add-hook' and `remove-hook' to manage this hook.") @@ -308,6 +308,9 @@ between reducing flickering and maintaining responsiveness." (declare-function flycheck-error-line "flycheck") (declare-function flycheck-error-message "flycheck") +;;;; Forward declarations for server +(defvar server-eval-args-left) + ;;;; Internal state variables (defvar claude-code--directory-buffer-map (make-hash-table :test 'equal) "Hash table mapping directories to user-selected Claude buffers. @@ -681,8 +684,8 @@ SWITCHES are optional command-line arguments for PROGRAM." (let* ((vterm-shell (if switches (concat program " " (mapconcat #'identity switches " ")) program)) - (vterm-environment (cons (format "CLAUDE_BUFFER_NAME=%s" buffer-name) - vterm-environment)) + (process-environment (cons (format "CLAUDE_BUFFER_NAME=%s" buffer-name) + process-environment)) (buffer (get-buffer-create buffer-name))) (with-current-buffer buffer ;; vterm needs to have an open window before starting the claude @@ -1368,16 +1371,19 @@ MESSAGE is the notification body." (claude-code--pulse-modeline) (message "%s: %s" title message)) - -(defun claude-code-handle-hook (type buffer-name json-tmpfile &rest args) - "Handle hook of TYPE for BUFFER-NAME with JSON data from JSON-TMPFILE. -Additional ARGS can be passed for extensibility." - (when (file-exists-p json-tmpfile) - (let ((json-data (with-temp-buffer - (insert-file-contents json-tmpfile) - (buffer-string)))) - (let ((message (list :type type :buffer-name buffer-name :json-data json-data :args args))) - (run-hook-with-args 'claude-code-hook message))))) +(defun claude-code-handle-hook (type buffer-name &rest args) + "Handle hook of TYPE for BUFFER-NAME with JSON data and additional ARGS. +This is the unified entry point for all Claude Code CLI hooks. +ARGS can contain additional arguments passed from the CLI." + ;; Must consume ALL arguments from server-eval-args-left to prevent Emacs + ;; from trying to evaluate leftover arguments as Lisp expressions + (let ((json-data (when server-eval-args-left (pop server-eval-args-left))) + (extra-args (prog1 server-eval-args-left (setq server-eval-args-left nil)))) + (let ((message (list :type type + :buffer-name buffer-name + :json-data json-data + :args (append args extra-args)))) + (run-hook-with-args 'claude-code-event-hook message)))) (defun claude-code--notify (_terminal) "Notify the user that Claude has finished and is awaiting input. @@ -1719,10 +1725,10 @@ With two prefix ARGs, both add instructions and switch to Claude buffer." (let ((file-path (claude-code--get-buffer-file-name))) (if file-path (let* ((prompt (when arg - (read-string "Instructions for Claude: "))) + (read-string "Instructions for Claude: "))) (command (if prompt - (format "%s\n\n@%s" prompt file-path) - (format "@%s" file-path)))) + (format "%s\n\n@%s" prompt file-path) + (format "@%s" file-path)))) (let ((selected-buffer (claude-code--do-send-command command))) (when (and (equal arg '(16)) selected-buffer) ; Only switch buffer with C-u C-u (pop-to-buffer selected-buffer)))) diff --git a/examples/hooks/claude-code-hook-examples.el b/examples/hooks/claude-code-hook-examples.el index 6db61f4..0f10c1e 100644 --- a/examples/hooks/claude-code-hook-examples.el +++ b/examples/hooks/claude-code-hook-examples.el @@ -7,17 +7,19 @@ ;;; Commentary: ;; This file provides examples of how to configure and use Claude Code hooks. +;; It includes both basic examples and enhanced examples showing how to pass +;; additional data beyond JSON using server-eval-args-left. ;; Copy and adapt these examples to your own configuration. ;;; Code: -;;;; Example Hook Handlers +;;;; Basic Hook Listeners -;; Uses the new hook API where claude-code-handle-hook creates a plist message +;; Uses the hook API where claude-code-handle-hook creates a plist message ;; with :type, :buffer-name, :json-data, and :args keys -(defun my-claude-notification-handler (message) - "Handle Claude notification hooks with visual and audio feedback. +(defun my-claude-notification-listener (message) + "Handle Claude notification events with visual and audio feedback. MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." (let ((hook-type (plist-get message :type)) (buffer-name (plist-get message :buffer-name)) @@ -39,7 +41,7 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." (message "✅ Claude finished responding in %s! JSON: %s" buffer-name json-data) (ding))))) -(defun my-claude-tool-use-tracker (message) +(defun my-claude-tool-use-listener (message) "Track Claude's tool usage for debugging/monitoring. MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." (let ((hook-type (plist-get message :type)) @@ -51,7 +53,7 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." ((eq hook-type 'post-tool-use) (message "✅ Claude finished using a tool in %s. JSON: %s" buffer-name json-data))))) -(defun my-claude-session-logger (message) +(defun my-claude-session-listener (message) "Log all Claude hook events to a file. MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." (let ((hook-type (plist-get message :type)) @@ -62,7 +64,7 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." (insert (format "[%s] %s: %s (JSON: %s)\n" timestamp hook-type buffer-name json-data)) (append-to-file (point-min) (point-max) "~/claude-hooks.log")))) -(defun my-claude-org-task-manager (message) +(defun my-claude-org-listener (message) "Create org-mode entries for Claude sessions. MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." (let ((hook-type (plist-get message :type)) @@ -81,44 +83,46 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." json-data)) (append-to-file (point-min) (point-max) org-file))))))) + ;;;; Hook Setup Examples (defun setup-claude-hooks-basic () "Set up basic Claude hook handling with notifications." (interactive) - (add-hook 'claude-code-hook 'my-claude-notification-handler) + (add-hook 'claude-code-event-hook 'my-claude-notification-listener) (message "Basic Claude hooks configured")) (defun setup-claude-hooks-advanced () - "Set up advanced Claude hook handling with multiple handlers." + "Set up advanced Claude hook handling with multiple listeners." (interactive) - ;; Add multiple handlers - (add-hook 'claude-code-hook 'my-claude-notification-handler) - (add-hook 'claude-code-hook 'my-claude-tool-use-tracker) - (add-hook 'claude-code-hook 'my-claude-session-logger) + ;; Add multiple listeners + (add-hook 'claude-code-event-hook 'my-claude-notification-listener) + (add-hook 'claude-code-event-hook 'my-claude-tool-use-listener) + (add-hook 'claude-code-event-hook 'my-claude-session-listener) (message "Advanced Claude hooks configured")) (defun setup-claude-hooks-org-integration () "Set up Claude hooks with org-mode integration." (interactive) - (add-hook 'claude-code-hook 'my-claude-notification-handler) - (add-hook 'claude-code-hook 'my-claude-org-task-manager) + (add-hook 'claude-code-event-hook 'my-claude-notification-listener) + (add-hook 'claude-code-event-hook 'my-claude-org-listener) (message "Claude hooks with org-mode integration configured")) + ;;;; Utility Functions (defun remove-all-claude-hooks () "Remove all Claude hook handlers." (interactive) - (setq claude-code-hook nil) + (setq claude-code-event-hook nil) (message "All Claude hooks removed")) (defun list-claude-hooks () "Show currently configured Claude hook handlers." (interactive) - (if claude-code-hook + (if claude-code-event-hook (message "Claude hooks: %s" - (mapconcat (lambda (f) (symbol-name f)) claude-code-hook ", ")) + (mapconcat (lambda (f) (symbol-name f)) claude-code-event-hook ", ")) (message "No Claude hooks configured"))) ;;;; Usage Instructions @@ -128,6 +132,9 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." ;; 1. Load this file: (load-file "claude-code-hook-examples.el") ;; 2. Set up hooks: (setup-claude-hooks-basic) ; or one of the other setup functions ;; 3. Configure Claude Code CLI hooks in .claude/settings.json: + +;;;; Basic Configuration (JSON data only): +;; Use this with the basic listeners (my-claude-notification-listener, etc.) ;; ;; { ;; "hooks": { @@ -137,7 +144,7 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." ;; "hooks": [ ;; { ;; "type": "command", -;; "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" +;; "command": "emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" ;; } ;; ] ;; } @@ -148,7 +155,7 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." ;; "hooks": [ ;; { ;; "type": "command", -;; "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" +;; "command": "emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" ;; } ;; ] ;; } @@ -156,6 +163,86 @@ MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." ;; } ;; } +;;;; Configuration with additional arguments: +;; Use this with my-claude-context-listener to access extra context data +;; +;; { +;; "hooks": { +;; "Notification": [ +;; { +;; "matcher": "", +;; "hooks": [ +;; { +;; "type": "command", +;; "command": "emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\" \"$PWD\" \"$(date -Iseconds)\" \"$$\"" +;; } +;; ] +;; } +;; ], +;; "Stop": [ +;; { +;; "matcher": "", +;; "hooks": [ +;; { +;; "type": "command", +;; "command": "emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\" \"$PWD\" \"$(date -Iseconds)\" \"$$\"" +;; } +;; ] +;; } +;; ] +;; } +;; } +;; +;; This enhanced configuration passes: +;; - JSON data from stdin (always required) +;; - Current working directory ($PWD) +;; - Timestamp ($(date -Iseconds)) +;; - Process ID ($$) +;; +;; The my-claude-context-listener function demonstrates how to extract and use this extra data. + +(defun my-claude-context-listener (message) + "Event listener that demonstrates using extra arguments passed from CLI. +MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys. +The :args field contains additional data like working directory, timestamp, and PID +when using the configuration with additional arguments." + (let ((hook-type (plist-get message :type)) + (buffer-name (plist-get message :buffer-name)) + (json-data (plist-get message :json-data)) + (args (plist-get message :args))) + (cond + ((eq hook-type 'notification) + ;; Extract additional arguments if they were passed + (if args + (let ((working-dir (nth 0 args)) + (timestamp (nth 1 args)) + (process-id (nth 2 args))) + (message "🤖 Claude ready in %s! Working dir: %s, Time: %s, PID: %s" + buffer-name working-dir timestamp process-id) + ;; Could log with more context + (with-temp-buffer + (insert (format "[%s] Claude ready in %s (dir: %s, PID: %s) - JSON: %s\n" + timestamp buffer-name working-dir process-id json-data)) + (append-to-file (point-min) (point-max) "~/claude-context.log"))) + ;; Fallback for basic configuration without extra args + (message "🤖 Claude ready in %s! JSON: %s" buffer-name json-data))) + ((eq hook-type 'stop) + (if args + (let ((working-dir (nth 0 args)) + (timestamp (nth 1 args)) + (process-id (nth 2 args))) + (message "✅ Claude finished in %s! Working dir: %s, Time: %s, PID: %s" + buffer-name working-dir timestamp process-id)) + (message "✅ Claude finished in %s! JSON: %s" buffer-name json-data)))))) + +(defun setup-claude-hooks-with-context () + "Set up Claude hooks that use extra CLI arguments. +Use this with the configuration that passes additional arguments like $PWD, timestamp, and PID." + (interactive) + (add-hook 'claude-code-event-hook 'my-claude-context-listener) + (message "Claude hooks with context awareness configured - use the configuration with additional arguments")) + + (provide 'claude-code-hook-examples) ;;; claude-code-hook-examples.el ends here diff --git a/examples/hooks/example_settings.json b/examples/hooks/example_settings.json index 5204d77..2862b74 100644 --- a/examples/hooks/example_settings.json +++ b/examples/hooks/example_settings.json @@ -10,7 +10,7 @@ "hooks": [ { "type": "command", - "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + "command": "emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" } ] } @@ -21,7 +21,7 @@ "hooks": [ { "type": "command", - "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + "command": "emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" } ] } @@ -32,7 +32,7 @@ "hooks": [ { "type": "command", - "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'pre-tool-use \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + "command": "emacsclient --eval \"(claude-code-handle-hook 'pre-tool-use \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" } ] } @@ -43,7 +43,7 @@ "hooks": [ { "type": "command", - "command": "tmpfile=$(mktemp); cat > \"$tmpfile\"; /opt/homebrew/bin/emacsclient --eval \"(claude-code-handle-hook 'post-tool-use \\\"$CLAUDE_BUFFER_NAME\\\" \\\"$tmpfile\\\")\"; rm \"$tmpfile\"" + "command": "emacsclient --eval \"(claude-code-handle-hook 'post-tool-use \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" } ] } From ac987ad96fe2ff770ebf3362273e79bffd90f5b5 Mon Sep 17 00:00:00 2001 From: Elle Najt Date: Sat, 26 Jul 2025 21:20:57 -0400 Subject: [PATCH 3/3] Support Claude Code hooks with org-mode task tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add claude-code-event-hook system for unified event handling - Implement claude-code-org-notifications.el for persistent task tracking - Add smart notification system with queue management and workspace integration - Support auto-advance queue mode for streamlined task processing - Include perspective.el integration for workspace navigation - Add comprehensive documentation for hooks and notification features 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CHANGELOG.md | 20 - README.md | 253 ++++++----- claude-code-org-notifications.el | 708 +++++++++++++++++++++++++++++++ claude-code.el | 35 +- 4 files changed, 886 insertions(+), 130 deletions(-) create mode 100644 claude-code-org-notifications.el diff --git a/CHANGELOG.md b/CHANGELOG.md index b24f54e..d815c05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,6 @@ All notable changes to claude-code.el will be documented in this file. -### [0.4.3] - -### Added -- New `claude-code-vterm-multiline-delay` customization variable to control the delay before processing buffered vterm output - - Default value changed from 0.001 to 0.01 seconds (10ms) to better reduce flickering - - Allows fine-tuning the balance between flickering reduction and responsiveness - -### Fixed -- Fix bug in eat keybindings - -## [0.4.2] - -### Changed -- File references now use `@file:line` format instead of verbose context format - -## [0.4.1] - -### Changed -- upgrade to the latest transient release - ## [0.4.0] ### Changed diff --git a/README.md b/README.md index 148ab5f..8e62e9c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ An Emacs interface for [Claude Code CLI](https://github.com/anthropics/claude-co - **Continue Conversations** - Resume previous sessions or fork to earlier points - **Read-Only Mode** - Toggle to select and copy text with normal Emacs commands and keybindings - **Mode Cycling** - Quick switch between default, auto-accept edits, and plan modes -- **Desktop Notifications** - Get notified when Claude finishes processing +- **Enhanced Notifications** - Clickable notifications with optional Org mode task tracking +- **Workspace Integration** - Navigate to workspaces containing Claude buffers with perspective.el support - **Terminal Choice** - Works with both eat and vterm backends - **Fully Customizable** - Configure keybindings, notifications, and display preferences @@ -26,6 +27,7 @@ An Emacs interface for [Claude Code CLI](https://github.com/anthropics/claude-co - [Claude Code CLI](https://github.com/anthropics/claude-code) installed and configured - Required: transient (0.7.5+) - Optional: eat (0.9.2+) for eat backend, vterm for vterm backend +- Optional: perspective.el for workspace navigation features ### Using builtin use-package (Emacs 30+) @@ -220,6 +222,20 @@ You can change this behavior by customizing `claude-code-newline-keybinding-styl - `claude-code-send-2` (`C-c c 2`) - Send "2" to Claude (useful for selecting the second option when Claude presents a numbered menu) - `claude-code-send-3` (`C-c c 3`) - Send "3" to Claude (useful for selecting the third option when Claude presents a numbered menu) +#### Workspace Navigation Commands + +- `claude-code-goto-recent-workspace` (`C-c c w`) - Go to the most recent workspace from the taskmaster org file +- `claude-code-goto-recent-workspace-and-clear` (`C-c c W`) - Go to the most recent workspace and mark the org entry as DONE + +#### Queue Management Commands + +- `claude-code-queue-browse` (`C-c c q`) - Browse and select from the task queue using minibuffer completion +- `claude-code-queue-next` - Navigate to the next entry in the task queue +- `claude-code-queue-previous` - Navigate to the previous entry in the task queue +- `claude-code-queue-skip` - Skip (delete) the current queue entry and advance to the next +- `claude-code-queue-status` - Show current queue position and total number of entries +- `claude-code-toggle-auto-advance-queue` - Toggle auto-advance mode on/off + ## Desktop Notifications claude-code.el notifies you when Claude finishes processing and is waiting for input. By default, it displays a message in the minibuffer and pulses the modeline for visual feedback. @@ -290,113 +306,133 @@ For Windows, you can use PowerShell to create toast notifications: *Note: Linux and Windows examples are untested. Feedback and improvements are welcome!* -### Claude Code Hooks Integration +### Enhanced Notification System + +The enhanced notification system provides smart, context-aware notifications with queue management: -claude-code.el provides integration to **receive** hook events from Claude Code CLI via emacsclient. +- **Smart Visibility Detection**: Popup notifications only appear when the Claude buffer is not currently visible in your active perspective +- **Always Queue**: Task entries are always added to the taskmaster.org file regardless of buffer visibility +- **Queue Counter**: Notifications show the current number of entries in the task queue +- **Auto-dismiss**: Simple notifications auto-dismiss after 2 seconds to reduce clutter +- **No Duplicates**: Each buffer is limited to one queue entry (existing entries are replaced) +- **Automatic Cleanup**: Queue entries are automatically removed when Claude buffers are closed -See [`examples/hooks/claude-code-hook-examples.el`](examples/hooks/claude-code-hook-examples.el) for comprehensive examples of hook listeners and setup functions. +### Org Mode Task Tracking -#### Hook API +claude-code.el includes an optional Org mode integration that automatically tracks completed Claude tasks in a persistent log: -- `claude-code-event-hook` - Emacs hook run when Claude Code CLI triggers events -- `claude-code-handle-hook` - **Unified entry point** for all Claude Code CLI hooks. Call this from your CLI hooks with `(type buffer-name &rest args)` and JSON data as additional emacsclient arguments +#### Features + +- **Persistent Task Log**: All completed Claude tasks are saved to `~/.claude/taskmaster.org` +- **Automatic Timestamps**: Each task entry includes completion timestamp +- **Clickable Buffer Links**: Elisp links in org entries allow instant buffer switching +- **Smart Display**: Popup notifications only appear when Claude buffer is not currently visible (taskmaster.org entries are always created) +- **Dual Event Tracking**: Captures both task completion and session stop events +- **Automatic Queue Cleanup**: Queue entries are automatically removed when Claude buffers are closed +- **No Duplicates**: Each buffer is limited to one queue entry to prevent clutter #### Setup -Before configuring hooks, you need to start the Emacs server so that `emacsclient` can communicate with your Emacs instance: +To enable Org mode notifications, add this to your configuration: ```elisp -;; Start the Emacs server (add this to your init.el) -(start-server) +;; Load the org notifications system +(require 'claude-code-org-notifications) + +;; Set up the hook listener to receive events +(claude-code-org-notifications-setup) -;; Add your hook listeners using standard Emacs functions -(add-hook 'claude-code-event-hook 'my-claude-hook-listener) +;; Configure Claude Code CLI hooks in settings.json (this will set up the CLI side) +(claude-code-setup-hooks) ``` -#### Custom Hook Listener +This will: +1. Set up the Emacs hook listener to receive Claude Code events +2. Create the necessary directory structure (`~/.claude/`) +3. Generate or update your Claude Code settings.json with notification hooks +4. Enable automatic task logging to the taskmaster.org file + +#### Hook Integration + +The org-mode notifications now use the new Claude Code hooks system introduced in the supportClaudeCodeHooks branch. This provides better integration and more reliable event handling. -Hook listeners receive a message plist with these keys: -- `:type` - Hook type (e.g., `'notification`, `'stop`, `'pre-tool-use`, `'post-tool-use`) -- `:buffer-name` - Claude buffer name from `$CLAUDE_BUFFER_NAME` -- `:json-data` - JSON payload from Claude CLI -- `:args` - List of additional arguments (when using extended configuration) +#### Manual Hook Configuration + +If you prefer to manually configure hooks or already have a settings.json file, you can call: ```elisp -;; Define your own hook listener function -(defun my-claude-hook-listener (message) - "Custom listener for Claude Code hooks. -MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys." - (let ((hook-type (plist-get message :type)) - (buffer-name (plist-get message :buffer-name)) - (json-data (plist-get message :json-data)) - (args (plist-get message :args))) - (cond - ((eq hook-type 'notification) - (message "Claude is ready in %s! JSON: %s" buffer-name json-data)) - ((eq hook-type 'stop) - (message "Claude finished in %s! JSON: %s" buffer-name json-data)) - (t - (message "Claude hook: %s with JSON: %s" hook-type json-data))))) - -;; Add the hook listener using standard Emacs hook functions -(add-hook 'claude-code-event-hook 'my-claude-hook-listener) +(claude-code-setup-hooks) ``` -See the examples file for complete listeners that demonstrate notifications, logging, org-mode integration, and using extra arguments from the `:args` field. - -#### Claude Code CLI Configuration - -Configure Claude Code CLI hooks to call `claude-code-handle-hook` via emacsclient by passing JSON data as an additional argument: - -```json -{ - "hooks": { - "Notification": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "emacsclient --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" - } - ] - } - ], - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "emacsclient --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" - } - ] - } - ] - } -} -``` +This function intelligently merges notification hooks with your existing configuration. + +#### Hook Context Variables + +Claude Code automatically exports the `CLAUDE_BUFFER_NAME` environment variable to the shell session, making it available to hooks and child processes. This allows hooks to: + +- Identify which Claude buffer triggered the notification +- Pass buffer context to external notification handlers +- Enable buffer-specific actions in custom scripts + +The environment variable contains the full buffer name (e.g., `*claude:/path/to/project:default*`) and is automatically set when Claude starts. + +#### Queue Browser and Navigation + +The notification system includes a powerful queue browser for managing multiple completed tasks: -The command pattern: -```bash -emacsclient --eval "(claude-code-handle-hook 'notification \"$CLAUDE_BUFFER_NAME\")" "$(cat)" "ARG1" "ARG2" "ARG3" +- **Queue Browser**: Use `C-c c q` to browse and select from the task queue using minibuffer completion +- **Numbered Entries**: Tasks are displayed as numbered list (e.g., "1. *claude:/path/to/project:default*") +- **Direct Navigation**: Select any queue entry to instantly jump to that Claude buffer and workspace +- **Position Tracking**: The system remembers your current position in the queue across commands + +#### Workspace Integration + +The notification system includes workspace support that integrates with project-based workflows: + +- **Automatic Workspace Detection**: Claude automatically detects your current project/workspace directory when starting +- **Clickable Workspace Links**: Org mode entries include clickable workspace links that switch to the workspace directory +- **Workspace Buttons**: Notification popups include "Open Workspace" and "Open & Clear" buttons for quick workspace switching +- **Multiple Instance Support**: Works seamlessly with multiple Claude instances in the same workspace +- **Keyboard Commands**: Use `C-c c w` to go to the most recent workspace or `C-c c W` to go there and clear the org entry + +When Claude completes a task, the workspace information is automatically extracted from the buffer name and included in both the org mode log entries and notification popups. The "Open & Clear" button and `C-c c W` command allow you to quickly navigate to a workspace and mark the corresponding org entry as DONE, helping you maintain a clean task queue. + +#### Queue Navigation Commands + +The notification system includes several commands for navigating and managing the task queue: + +- **Browse Queue**: Use `claude-code-queue-browse` to view and select from all queue entries using minibuffer completion +- **Next Entry**: Use `claude-code-queue-next` to advance to the next entry in the queue +- **Previous Entry**: Use `claude-code-queue-previous` to go back to the previous queue entry +- **Skip Entry**: Use `claude-code-queue-skip` to delete the current queue entry and advance to the next +- **Queue Status**: Use `claude-code-queue-status` to show your current position and total queue size + +These commands maintain queue position tracking, so you can navigate through your completed tasks systematically. The queue browser provides the most user-friendly interface with numbered entries and completion. + +#### Auto-Advance Queue Mode + +Claude-code.el includes an optional auto-advance mode for streamlined queue processing: + +- **Auto-Advance Mode**: Enable `claude-code-auto-advance-queue` to automatically advance through the task queue +- **Seamless Workflow**: When enabled, pressing enter in any Claude buffer clears it from the queue and jumps to the next waiting task +- **Smart Navigation**: Automatically switches to a different Claude buffer's workspace and enters insert mode (with evil-mode) +- **Intelligent Filtering**: Only advances to different Claude buffers, never stays in the current buffer +- **Queue Status**: Shows remaining queue count when advancing +- **Toggle Command**: Use `claude-code-toggle-auto-advance-queue` to quickly enable/disable the mode + +To enable auto-advance mode in your configuration: + +```elisp +(setq claude-code-auto-advance-queue t) ``` -Where: -- `"$(cat)"` - JSON data from stdin (always required) -- `ARG1` is `"$PWD"` - current working directory -- `ARG2` is `"$(date -Iseconds)"` - timestamp -- `ARG3` is `"$$"` - process ID +Or toggle it interactively: -`claude-code-handle-hook` creates a message plist sent to listeners: ```elisp -(list :type 'notification - :buffer-name "$CLAUDE_BUFFER_NAME" - :json-data "$(cat)" - :args '("ARG1" "ARG2" "ARG3")) +M-x claude-code-toggle-auto-advance-queue ``` -See the [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/claude-code/hooks) for details on setting up CLI hooks. +This mode is perfect for processing multiple completed Claude tasks efficiently - just respond to each task and you'll automatically be taken to the next different one. The system ensures you never get stuck in the same buffer and always advance to a truly different Claude instance. ## Tips and Tricks @@ -410,19 +446,19 @@ See the [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/cla (setq auto-revert-use-notify nil) ``` -## Customization +## Customization {#customization} ```elisp -;; Set your key binding for the command map. +;; Set your key binding for the command map (global-set-key (kbd "C-c C-a") claude-code-command-map) -;; Set terminal type for the Claude terminal emulation (default is "xterm-256color"). -;; This determines terminal capabilities like color support. -;; See the documentation for eat-term-name for more information. +;; Set terminal type for the Claude terminal emulation (default is "xterm-256color") +;; This determines terminal capabilities like color support +;; See the documentation for eat-term-name for more information (setq claude-code-term-name "xterm-256color") -;; Change the path to the Claude executable (default is "claude"). -;; Useful if Claude is not in your PATH or you want to use a specific version. +;; Change the path to the Claude executable (default is "claude") +;; Useful if Claude is not in your PATH or you want to use a specific version (setq claude-code-program "/usr/local/bin/claude") ;; Set command line arguments for Claude @@ -433,15 +469,15 @@ See the [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/cla (add-hook 'claude-code-start-hook 'my-claude-setup-function) ;; Adjust initialization delay (default is 0.1 seconds) -;; This helps prevent terminal layout issues if the buffer is displayed before Claude is fully ready. +;; This helps prevent terminal layout issues if the buffer is displayed before Claude is fully ready (setq claude-code-startup-delay 0.2) ;; Configure the buffer size threshold for confirmation prompt (default is 100000 characters) ;; If a buffer is larger than this threshold, claude-code-send-region will ask for confirmation -;; before sending the entire buffer to Claude. +;; before sending the entire buffer to Claude (setq claude-code-large-buffer-threshold 100000) -;; Configure key binding style for entering newlines and sending messages in Claude buffers. +;; Configure key binding style for entering newlines and sending messages in Claude buffers ;; Available styles: ;; 'newline-on-shift-return - S-return inserts newline, RET sends message (default) ;; 'newline-on-alt-return - M-return inserts newline, RET sends message @@ -449,12 +485,12 @@ See the [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/cla ;; 'super-return-to-send - RET inserts newline, s-return sends message (Command+Return on macOS) (setq claude-code-newline-keybinding-style 'newline-on-shift-return) -;; Enable or disable notifications when Claude finishes and awaits input (default is t). +;; Enable or disable notifications when Claude finishes and awaits input (default is t) (setq claude-code-enable-notifications t) -;; Customize the notification function (default is claude-code--default-notification). -;; The function should accept two arguments: title and message. -;; The default function displays a message and pulses the modeline for visual feedback. +;; Customize the notification function (default is claude-code--default-notification) +;; The function should accept two arguments: title and message +;; The default function displays a message and pulses the modeline for visual feedback (setq claude-code-notification-function 'claude-code--default-notification) ;; Example: Use your own notification function @@ -464,9 +500,9 @@ See the [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/cla (message "[%s] %s" title message)) (setq claude-code-notification-function 'my-claude-notification) -;; Configure kill confirmation behavior (default is t). -;; When t, claude-code-kill prompts for confirmation before killing instances. -;; When nil, kills Claude instances without confirmation. +;; Configure kill confirmation behavior (default is t) +;; When t, claude-code-kill prompts for confirmation before killing instances +;; When nil, kills Claude instances without confirmation (setq claude-code-confirm-kill t) ;; Enable/disable window resize optimization (default is t) @@ -482,6 +518,12 @@ See the [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/cla ;; when you run delete-other-windows or similar commands, keeping the ;; Claude buffer visible and accessible. (setq claude-code-no-delete-other-windows t) + +;; Enable auto-advance queue mode (default is nil) +;; When enabled, pressing enter in a Claude buffer will clear it from the +;; task queue and automatically advance to the next queue entry. +;; This provides a streamlined workflow for processing multiple completed tasks. +(setq claude-code-auto-advance-queue t) ``` ### Customizing Window Position @@ -591,7 +633,6 @@ Or to apply it only to Claude buffers: (lambda () ;; Reduce line spacing to fix vertical bar gaps (setq-local line-spacing 0.1))) -``` ## Demo @@ -632,15 +673,9 @@ When using the vterm terminal backend, there are additional customization option ```elisp ;; Enable/disable buffering to prevent flickering on multi-line input (default is t) ;; When enabled, vterm output that appears to be redrawing multi-line input boxes -;; will be buffered briefly and processed in a single batch +;; will be buffered briefly (1ms) and processed in a single batch ;; This prevents flickering when Claude redraws its input box as it expands (setq claude-code-vterm-buffer-multiline-output t) - -;; Control the delay before processing buffered vterm output (default is 0.01) -;; This is the time in seconds that vterm waits to collect output bursts -;; A longer delay may reduce flickering more but could feel less responsive -;; The default of 0.01 seconds (10ms) provides a good balance -(setq claude-code-vterm-multiline-delay 0.01) ``` #### Vterm Scrollback Configuration diff --git a/claude-code-org-notifications.el b/claude-code-org-notifications.el new file mode 100644 index 0000000..f40489f --- /dev/null +++ b/claude-code-org-notifications.el @@ -0,0 +1,708 @@ +;;; claude-code-org-notifications.el --- Org mode notification queue for Claude Code -*- lexical-binding: t; -*- + +;; Author: Claude AI +;; Version: 0.1.0 +;; Package-Requires: ((emacs "30.0") (claude-code "0.2.0") (org "9.0")) +;; Keywords: tools, ai, org + +;;; Commentary: +;; This package extends claude-code.el with org mode notification queue functionality. +;; It provides persistent task tracking in ~/.claude/taskmaster.org with timestamps +;; and clickable buffer links, plus smart popup notifications that only appear when +;; the Claude buffer isn't currently visible. + +;;; Code: + +(require 'json) +(require 'cl-lib) + +;; Forward declarations for claude-code functions +(declare-function claude-code-handle-hook "claude-code") +(defvar claude-code-event-hook) + +;; Declare functions from perspective.el +(declare-function persp-names "persp-mode") +(declare-function persp-get-by-name "persp-mode") +(declare-function persp-switch "persp-mode") +(declare-function persp-buffers "persp-mode") + +;; Declare functions from org-mode +(declare-function org-back-to-heading "org") +(declare-function org-next-visible-heading "org") + +;; Declare functions from evil (optional) +(declare-function evil-insert-state "evil-states") + +;; Constants +(defconst claude-code-notification-buffer-name "*Claude Code Notification*" + "Name of the notification buffer.") + +(defconst claude-code-org-todo-pattern "^\* TODO Claude task completed" + "Pattern to match Claude task entries in org file.") + +;;;; Customization + +(defcustom claude-code-taskmaster-org-file (expand-file-name "~/.claude/taskmaster.org") + "Path to the org mode file for storing Claude task notifications. + +This file will contain a queue of completed Claude tasks as TODO entries +with timestamps and links back to the original Claude buffers." + :type 'file + :group 'claude-code) + +(defcustom claude-code-auto-advance-queue nil + "Whether to automatically advance to the next queue entry after sending input. + +When non-nil, pressing enter (or sending any input) in a Claude buffer will: +1. Clear the current buffer from the task queue +2. Automatically switch to the next Claude buffer in the queue + +This provides a streamlined workflow for processing multiple completed tasks." + :type 'boolean + :group 'claude-code) + +;;;; Org mode integration functions + +(defun claude-code--ensure-claude-directory () + "Ensure the Claude directory exists for storing taskmaster.org." + (let ((claude-dir (file-name-directory claude-code-taskmaster-org-file))) + (unless (file-directory-p claude-dir) + (make-directory claude-dir t)))) + +(defun claude-code--format-org-timestamp () + "Format current time as an org mode timestamp." + (format-time-string "[%Y-%m-%d %a %H:%M]")) + +(defun claude-code--get-workspace-from-buffer-name (buffer-name) + "Extract workspace directory from Claude BUFFER-NAME. +For example, *claude:/path/to/project/* returns /path/to/project/." + (when (and buffer-name (string-match "^\\*claude:\\([^:]+\\)\\(?::\\([^*]+\\)\\)?\\*$" buffer-name)) + (match-string 1 buffer-name))) + +(defun claude-code--add-org-todo-entry (buffer-name message) + "Add a TODO entry to the taskmaster org file. + +BUFFER-NAME is the name of the Claude buffer that completed a task. +MESSAGE is the notification message to include in the TODO entry. + +If an entry for the same buffer already exists, it will be removed first +to prevent duplicate entries in the queue." + (claude-code--ensure-claude-directory) + ;; First, remove any existing entry for this buffer + (when buffer-name + (claude-code--delete-queue-entry-for-buffer buffer-name)) + + (let* ((timestamp (claude-code--format-org-timestamp)) + (buffer-link (if buffer-name + (format "[[elisp:(switch-to-buffer \"%s\")][%s]]" buffer-name buffer-name) + "Unknown buffer"))) + (with-temp-buffer + (when (file-exists-p claude-code-taskmaster-org-file) + (insert-file-contents claude-code-taskmaster-org-file)) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert (format "* TODO Claude task completed %s\n" timestamp)) + (insert (format " Message: %s\n" (or message "Task completed"))) + (insert (format " Buffer: %s\n" buffer-link)) + (insert (format " Actions: [[elisp:(claude-code--switch-to-workspace-for-buffer \"%s\")][Go to Workspace]] | [[elisp:(claude-code--clear-current-org-entry-and-switch \"%s\")][Clear and Go to Workspace]]\n" buffer-name buffer-name)) + (insert "\n") + (write-region (point-min) (point-max) claude-code-taskmaster-org-file)))) + +(defun claude-code--get-most-recent-buffer () + "Get the most recent Claude buffer name from the taskmaster org file." + (when (file-exists-p claude-code-taskmaster-org-file) + (with-temp-buffer + (insert-file-contents claude-code-taskmaster-org-file) + (goto-char (point-max)) + (when (re-search-backward "Buffer: \\[\\[elisp:(switch-to-buffer \"\\([^\"]+\\)\")\\]\\[" nil t) + (match-string 1))))) + +(defun claude-code--find-workspace-for-buffer (buffer-name) + "Find the perspective that contains the specified BUFFER-NAME." + (when (featurep 'persp-mode) + (let ((target-buffer (get-buffer buffer-name))) + (when target-buffer + (cl-loop for persp-name in (persp-names) + for persp = (persp-get-by-name persp-name) + when (and persp + (member target-buffer (persp-buffers persp))) + return persp-name))))) + +(defun claude-code--switch-to-workspace-for-buffer (buffer-name) + "Switch to the perspective that contains BUFFER-NAME and navigate to it." + (if-let ((persp-name (claude-code--find-workspace-for-buffer buffer-name))) + (progn + (persp-switch persp-name) + (when-let ((target-buffer (get-buffer buffer-name))) + (if-let ((window (get-buffer-window target-buffer))) + ;; Buffer is visible, just select the window + (select-window window) + ;; Buffer is not visible, display it + (switch-to-buffer target-buffer)) + ;; If using evil mode and this is a Claude buffer, enter insert mode + (when (and (boundp 'evil-mode) evil-mode + (string-match-p "^\\*claude:" buffer-name)) + (evil-insert-state))) + (message "Switched to perspective: %s and navigated to buffer: %s" persp-name buffer-name) + persp-name) + (error "No perspective found for buffer: %s" buffer-name))) + +(defun claude-code--clear-most-recent-org-entry () + "Clear (mark as DONE) the most recent TODO entry in the taskmaster org file." + (when (file-exists-p claude-code-taskmaster-org-file) + (with-temp-buffer + (insert-file-contents claude-code-taskmaster-org-file) + (goto-char (point-max)) + (when (re-search-backward claude-code-org-todo-pattern nil t) + (replace-match "* DONE Claude task completed") + (write-region (point-min) (point-max) claude-code-taskmaster-org-file))))) + +(defun claude-code--clear-current-org-entry-and-switch (buffer-name) + "Delete the current TODO entry and switch to workspace for BUFFER-NAME." + (interactive) + (when (and (buffer-file-name) + (string= (file-name-nondirectory (buffer-file-name)) "taskmaster.org")) + ;; We're in the taskmaster.org file, delete current entry + (save-excursion + (org-back-to-heading t) + (when (looking-at claude-code-org-todo-pattern) + ;; Delete the entire entry (from heading to next heading or end of buffer) + (let ((start (point))) + (if (org-next-visible-heading 1) + (delete-region start (point)) + (delete-region start (point-max)))) + (save-buffer) + (message "Deleted entry and switching to workspace...") + ;; Switch to workspace + (claude-code--switch-to-workspace-for-buffer buffer-name))))) + +;;;; Notification dismissal system + +(defvar claude-code--notification-dismiss-active nil + "Whether notification dismiss mode is currently active.") + +(defvar claude-code--notification-buffer-name nil + "Name of the current notification buffer.") + +(defun claude-code--enable-notification-dismiss (buffer-name) + "Enable global notification dismissal for BUFFER-NAME." + (unless claude-code--notification-dismiss-active + (setq claude-code--notification-dismiss-active t + claude-code--notification-buffer-name buffer-name) + ;; Use overriding-local-map for higher precedence + (let ((map (make-sparse-keymap))) + (define-key map (kbd "") 'claude-code--dismiss-notification-if-visible) + (define-key map (kbd "q") 'claude-code--dismiss-notification-if-visible) + (setq overriding-local-map map)))) + +(defun claude-code--disable-notification-dismiss () + "Disable global notification dismissal and restore original ESC binding." + (when claude-code--notification-dismiss-active + (setq claude-code--notification-dismiss-active nil + claude-code--notification-buffer-name nil) + ;; Clear the overriding map + (setq overriding-local-map nil))) + +(defun claude-code--dismiss-notification-if-visible () + "Dismiss notification if visible." + (interactive) + (when (and claude-code--notification-dismiss-active + claude-code--notification-buffer-name + (get-buffer-window claude-code--notification-buffer-name)) + ;; Notification is visible, dismiss it + (kill-buffer claude-code--notification-buffer-name) + (claude-code--disable-notification-dismiss))) + +(defun claude-code--dismiss-and-kill-buffer (buffer-name) + "Helper to dismiss notification and kill BUFFER-NAME." + (claude-code--disable-notification-dismiss) + (kill-buffer buffer-name)) + +;;;; Enhanced notification system + +(defun claude-code--buffer-visible-in-current-perspective-p (buffer-name) + "Check if BUFFER-NAME is currently visible in the active perspective. + +Returns t if the buffer is visible in a window in the current perspective, +nil otherwise." + (when-let ((target-buffer (get-buffer buffer-name))) + (and + ;; Buffer exists and is live + (buffer-live-p target-buffer) + ;; Buffer has a visible window + (get-buffer-window target-buffer) + ;; If persp-mode is active, check if we're in the right perspective + (or (not (featurep 'persp-mode)) + (let ((buffer-persp (claude-code--find-workspace-for-buffer buffer-name)) + (current-persp (when (fboundp 'get-current-persp) + (let ((cp (get-current-persp))) + (when cp (persp-name cp)))))) + ;; Either buffer has no perspective (global) or we're in its perspective + (or (null buffer-persp) + (string= buffer-persp current-persp))))))) + +;;;###autoload +(defun claude-code-org-notification-listener (message) + "Handle Claude Code hook events for org-mode task tracking. + +MESSAGE is a plist with :type, :buffer-name, :json-data, and :args keys. +This is designed to work with the new claude-code-event-hook system." + (let ((hook-type (plist-get message :type)) + (buffer-name (plist-get message :buffer-name)) + (json-data (plist-get message :json-data))) + (cond + ((eq hook-type 'notification) + (claude-code--handle-task-completion buffer-name "Claude task completed" json-data)) + ((eq hook-type 'stop) + (claude-code--handle-task-completion buffer-name "Claude session stopped" json-data))))) + +(defun claude-code--handle-task-completion (buffer-name message json-data) + "Handle a Claude task completion event. + +BUFFER-NAME is the name of the Claude buffer. +MESSAGE is the notification message to display and log. +JSON-DATA is the JSON payload from Claude CLI." + (let* ((notification-buffer claude-code-notification-buffer-name) + (target-buffer (when buffer-name (get-buffer buffer-name))) + (has-workspace (and buffer-name + (claude-code--get-workspace-from-buffer-name buffer-name))) + (buffer-visible (claude-code--buffer-visible-in-current-perspective-p buffer-name))) + + ;; Always add entry to org file regardless of visibility + (claude-code--add-org-todo-entry buffer-name message) + + ;; Only show popup notification if buffer is not currently visible + (unless buffer-visible + (let ((queue-total (length (claude-code--get-all-queue-entries)))) + (with-current-buffer (get-buffer-create notification-buffer) + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (format "%s%s\nBuffer: %s" + message + (if (> queue-total 0) + (format " - %d in queue" queue-total) + "") + (or buffer-name "unknown buffer"))) + (goto-char (point-min)) + (setq buffer-read-only t)) + + ;; Display as small popup without stealing focus + (display-buffer notification-buffer + '((display-buffer-in-side-window) + (side . bottom) + (window-height . 1) + (select . nil))) + + ;; Auto-dismiss after 2 seconds + (run-with-timer 2 nil `(lambda () + (when (get-buffer ,notification-buffer) + (kill-buffer ,notification-buffer))))))) + + ;; Disabled complex popup - keeping code for potential future use + (when nil ;; Change to t to re-enable complex popups + (unless (and target-buffer + (or (get-buffer-window target-buffer) + (eq (current-buffer) target-buffer))) + ;; Create and display notification buffer + (with-current-buffer (get-buffer-create notification-buffer) + (let ((inhibit-read-only t) + (queue-total (length (claude-code--get-all-queue-entries)))) + (erase-buffer) + (insert (format "Claude notification: %s\n" (or message "Task completed"))) + (insert (format "Buffer: %s\n" (or buffer-name-override "unknown buffer"))) + ;; Add queue position information + (when (> queue-total 0) + (insert (format "Queue: %d entries\n" queue-total))) + (insert "\n") + + (if (and target-buffer (buffer-live-p target-buffer)) + (insert-button "Switch to Claude buffer" + 'action `(lambda (_button) + (when (buffer-live-p ,target-buffer) + (switch-to-buffer ,target-buffer) + ;; Enter insert mode if using evil + (when (and (boundp 'evil-mode) evil-mode + (string-match-p "^\\*claude:" ,buffer-name-override)) + (evil-insert-state)) + (claude-code--dismiss-and-kill-buffer ,notification-buffer))) + 'help-echo (format "Click to switch to %s" buffer-name-override)) + (insert (format "Buffer '%s' not found or no longer exists." (or buffer-name-override "unknown")))) + + (insert "\n") + (when has-workspace + (insert-button "Open Workspace" + 'action `(lambda (_button) + (claude-code--switch-to-workspace-for-buffer ,buffer-name-override) + (claude-code--dismiss-and-kill-buffer ,notification-buffer)) + 'help-echo (format "Click to switch to workspace for buffer: %s" buffer-name-override)) + (insert " ") + (insert-button "Open & Clear" + 'action `(lambda (_button) + (claude-code--switch-to-workspace-for-buffer ,buffer-name-override) + (claude-code--clear-most-recent-org-entry) + (claude-code--dismiss-and-kill-buffer ,notification-buffer)) + 'help-echo (format "Click to switch to workspace and clear org entry for buffer: %s" buffer-name-override)) + (insert "\n")) + + (insert "\n") + (insert-button "View Task Queue" + 'action `(lambda (_button) + (find-file ,claude-code-taskmaster-org-file) + (claude-code--dismiss-and-kill-buffer ,notification-buffer)) + 'help-echo "Click to view the org mode task queue") + (insert " ") + (insert-button "Skip Entry" + 'action `(lambda (_button) + (claude-code--delete-queue-entry-for-buffer ,buffer-name-override) + (claude-code--dismiss-and-kill-buffer ,notification-buffer) + (message "Skipped queue entry for %s" ,buffer-name-override)) + 'help-echo "Click to skip this queue entry") + + (goto-char (point-min))) + + ;; Display the notification buffer and set up dismissal + (display-buffer notification-buffer + '((display-buffer-in-side-window) + (side . bottom) + (window-height . 0.3) + (select . nil))) + (claude-code--enable-notification-dismiss notification-buffer) + + ;; Auto-dismiss timer + (run-with-timer 10 nil `(lambda () + (when (buffer-live-p (get-buffer ,notification-buffer)) + (claude-code--dismiss-and-kill-buffer ,notification-buffer))))))))) + +;;;###autoload +(defun claude-code-test-notification () + "Test the notification system interactively." + (interactive) + (claude-code-org-notification-listener + (list :type 'notification + :buffer-name (buffer-name) + :json-data "{\"test\": true}" + :args '()))) + +;;;; Settings.json configuration helper + +;;;###autoload +(defun claude-code-setup-hooks () + "Add or update Claude Code notification hooks in ~/.claude/settings.json." + (interactive) + (let* ((claude-dir (expand-file-name "~/.claude")) + (settings-file (expand-file-name "settings.json" claude-dir)) + (emacsclient-cmd (executable-find "emacsclient")) + (hooks-config `((hooks . ((Notification . [((matcher . "") + (hooks . [((type . "command") + (command . ,(format "%s --eval \"(claude-code-handle-hook 'notification \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" + emacsclient-cmd)))]))]) + + (Stop . [((matcher . "") + (hooks . [((type . "command") + (command . ,(format "%s --eval \"(claude-code-handle-hook 'stop \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" + emacsclient-cmd)))]))]))))) + (existing-config (when (file-exists-p settings-file) + (condition-case err + (json-read-file settings-file) + (error + (message "Warning: Could not parse existing settings.json: %s" (error-message-string err)) + nil)))) + (new-config (if existing-config + (let ((config-alist (if (hash-table-p existing-config) + (claude-code--hash-table-to-alist existing-config) + existing-config))) + (claude-code--merge-hooks-config config-alist hooks-config)) + hooks-config))) + + (unless emacsclient-cmd + (error "emacsclient not found in PATH. Please ensure Emacs server is properly installed")) + + ;; Ensure Claude directory exists + (unless (file-directory-p claude-dir) + (make-directory claude-dir t)) + + ;; Write updated config with pretty formatting + (with-temp-file settings-file + (let ((json-encoding-pretty-print t)) + (insert (json-encode new-config)))) + + (message "Claude Code notification hooks added to %s" settings-file))) + +(defun claude-code--hash-table-to-alist (hash-table) + "Convert HASH-TABLE to an alist." + (let (result) + (maphash (lambda (key value) + (push (cons key value) result)) + hash-table) + (nreverse result))) + +(defun claude-code--merge-hooks-config (existing-config hooks-config) + "Merge HOOKS-CONFIG into EXISTING-CONFIG, preserving other settings." + (let ((config-copy (copy-alist existing-config)) + (hooks-entry (assoc 'hooks hooks-config))) + (if (assoc 'hooks config-copy) + ;; Hooks section exists, merge it + (setcdr (assoc 'hooks config-copy) (cdr hooks-entry)) + ;; No hooks section, add it + (push hooks-entry config-copy)) + config-copy)) + +;;;; Workspace Navigation Commands + +;;;###autoload +(defun claude-code-goto-recent-workspace () + "Go to the most recent perspective from the taskmaster org file." + (interactive) + (if-let ((buffer-name (claude-code--get-most-recent-buffer))) + (claude-code--switch-to-workspace-for-buffer buffer-name) + (message "No recent perspective found in taskmaster.org"))) + +;;;###autoload +(defun claude-code-goto-recent-workspace-and-clear () + "Go to the most recent perspective and clear the org entry." + (interactive) + (if-let ((buffer-name (claude-code--get-most-recent-buffer))) + (progn + (claude-code--switch-to-workspace-for-buffer buffer-name) + (claude-code--clear-most-recent-org-entry) + (message "Switched to perspective and cleared org entry for buffer: %s" buffer-name)) + (message "No recent perspective found in taskmaster.org"))) + +;;;; Queue Navigation System + +(defvar claude-code--queue-position 0 + "Current position in the taskmaster.org queue.") + +(defun claude-code--get-all-queue-entries () + "Get all TODO entries from taskmaster.org as a list of buffer names." + (when (file-exists-p claude-code-taskmaster-org-file) + (with-temp-buffer + (insert-file-contents claude-code-taskmaster-org-file) + (goto-char (point-min)) + (let (entries) + (while (re-search-forward claude-code-org-todo-pattern nil t) + (when (re-search-forward "Buffer: \\[\\[elisp:(switch-to-buffer \"\\([^\"]+\\)\")\\]\\[" nil t) + (push (match-string 1) entries))) + (nreverse entries))))) + +(defun claude-code--get-queue-entry-at-position (position) + "Get the queue entry at POSITION, or nil if out of bounds." + (let ((entries (claude-code--get-all-queue-entries))) + (when (and entries (>= position 0) (< position (length entries))) + (nth position entries)))) + +(defun claude-code--delete-queue-entry-for-buffer (buffer-name) + "Delete the queue entry corresponding to BUFFER-NAME from taskmaster.org." + (when (file-exists-p claude-code-taskmaster-org-file) + (with-temp-buffer + (insert-file-contents claude-code-taskmaster-org-file) + (goto-char (point-min)) + (let (found) + (while (and (not found) (re-search-forward claude-code-org-todo-pattern nil t)) + (let ((entry-start (match-beginning 0))) + (when (re-search-forward (format "Buffer: \\[\\[elisp:(switch-to-buffer \"%s\")" (regexp-quote buffer-name)) nil t) + (goto-char entry-start) + (if (org-next-visible-heading 1) + (delete-region entry-start (point)) + (delete-region entry-start (point-max))) + (setq found t)))) + (when found + (write-region (point-min) (point-max) claude-code-taskmaster-org-file) + t))))) + +;;;###autoload +(defun claude-code-queue-next () + "Navigate to the next entry in the taskmaster.org queue." + (interactive) + (let* ((entries (claude-code--get-all-queue-entries)) + (total (length entries))) + (if (zerop total) + (message "No entries in queue") + (setq claude-code--queue-position (mod (1+ claude-code--queue-position) total)) + (let ((buffer-name (nth claude-code--queue-position entries))) + (claude-code--switch-to-workspace-for-buffer buffer-name) + (message "Queue position %d/%d: %s" (1+ claude-code--queue-position) total buffer-name))))) + +;;;###autoload +(defun claude-code-queue-previous () + "Navigate to the previous entry in the taskmaster.org queue." + (interactive) + (let* ((entries (claude-code--get-all-queue-entries)) + (total (length entries))) + (if (zerop total) + (message "No entries in queue") + (setq claude-code--queue-position (mod (1- claude-code--queue-position) total)) + (let ((buffer-name (nth claude-code--queue-position entries))) + (claude-code--switch-to-workspace-for-buffer buffer-name) + (message "Queue position %d/%d: %s" (1+ claude-code--queue-position) total buffer-name))))) + +;;;###autoload +(defun claude-code-queue-skip () + "Skip the current queue entry (delete it) and advance to the next." + (interactive) + (let* ((entries (claude-code--get-all-queue-entries)) + (total (length entries))) + (if (zerop total) + (message "No entries in queue") + (let ((current-buffer (nth claude-code--queue-position entries))) + (if (claude-code--delete-queue-entry-for-buffer current-buffer) + (progn + (message "Skipped entry for %s" current-buffer) + ;; Adjust position if we're at the end + (let ((new-total (length (claude-code--get-all-queue-entries)))) + (when (>= claude-code--queue-position new-total) + (setq claude-code--queue-position (max 0 (1- new-total)))) + (if (zerop new-total) + (message "Queue is now empty") + (claude-code-queue-next)))) + (message "Failed to skip entry for %s" current-buffer)))))) + +;;;###autoload +(defun claude-code-queue-status () + "Show the current queue status." + (interactive) + (let* ((entries (claude-code--get-all-queue-entries)) + (total (length entries))) + (if (zerop total) + (message "Queue is empty") + (message "Queue: %d/%d entries, current: %s" + (1+ claude-code--queue-position) total + (nth claude-code--queue-position entries))))) + +;;;###autoload +(defun claude-code-queue-browse () + "Browse and select from the taskmaster.org queue using minibuffer completion." + (interactive) + (let ((entries (claude-code--get-all-queue-entries))) + (if (null entries) + (message "Queue is empty") + (let* ((choices (cl-loop for entry in entries + for i from 0 + collect (cons (format "%d. %s" (1+ i) entry) entry))) + (selection (completing-read "Select queue entry: " choices nil t)) + (selected-buffer (cdr (assoc selection choices)))) + (when selected-buffer + ;; Update queue position to match selection + (setq claude-code--queue-position (cl-position selected-buffer entries :test #'string=)) + ;; Switch to the selected buffer + (claude-code--switch-to-workspace-for-buffer selected-buffer) + (message "Switched to queue entry: %s" selected-buffer)))))) + +;;;; Queue Cleanup on Buffer Kill + +(defun claude-code--cleanup-queue-entries () + "Remove taskmaster.org entries when Claude buffer is killed. + +This function is added to `kill-buffer-hook' in Claude buffers to automatically +clean up queue entries when the buffer is no longer available." + (let ((buffer-name (buffer-name))) + (when (and buffer-name (string-match-p "^\\*claude:" buffer-name)) + (claude-code--delete-queue-entry-for-buffer buffer-name)))) + +;;;; Automatic Entry Clearing on RET + +(defun claude-code--auto-clear-on-ret () + "Auto-clear taskmaster.org entry when user sends input. + +This function is added to the RET key in Claude buffers to provide +seamless queue progression." + (let ((buffer-name (buffer-name))) + (when (string-match-p "^\\*claude:" buffer-name) + (when (claude-code--delete-queue-entry-for-buffer buffer-name) + (message "Auto-cleared queue entry for %s" buffer-name))))) + +(defun claude-code--auto-advance-to-next () + "Clear current buffer from queue and advance to the next queue entry. + +This function clears the current Claude buffer from the task queue and +automatically switches to the next available queue entry. If no more +entries exist, it displays a message." + (let ((buffer-name (buffer-name))) + (when (and claude-code-auto-advance-queue + (string-match-p "^\\*claude:" buffer-name)) + ;; Clear current buffer from queue + (when (claude-code--delete-queue-entry-for-buffer buffer-name) + (message "Cleared queue entry for %s" buffer-name) + ;; Get remaining entries after clearing current one + (let* ((remaining-entries (claude-code--get-all-queue-entries)) + ;; Filter out the current buffer from remaining entries (in case it wasn't properly cleared) + (other-entries (cl-remove-if (lambda (buf-name) + (string= buf-name buffer-name)) + remaining-entries))) + (if other-entries + (progn + ;; Reset queue position to 0 and advance to first different entry + (setq claude-code--queue-position 0) + (let ((next-buffer (nth claude-code--queue-position other-entries))) + (claude-code--switch-to-workspace-for-buffer next-buffer) + (message "Auto-advanced to next queue entry: %s (%d remaining)" + next-buffer (length other-entries)))) + (message "Queue is now empty - no more entries to process"))))))) + +(defun claude-code--setup-auto-clear-hook () + "Set up automatic entry clearing for Claude buffers. +This function is added to `claude-code-start-hook' to enable automatic +queue progression when users respond to Claude." + (when (string-match-p "^\\*claude:" (buffer-name)) + ;; Use pre-command-hook to detect when user is about to send input + ;; This works better with terminal emulators than trying to override RET + (add-hook 'pre-command-hook #'claude-code--check-for-input nil t))) + +(defun claude-code--check-for-input () + "Check if user is sending input and auto-clear queue entry. +This runs on pre-command-hook in Claude buffers." + (when (and (string-match-p "^\\*claude:" (buffer-name)) + ;; Check if this is likely an input command (RET, sending text, etc.) + (or (eq this-command 'self-insert-command) + (eq this-command 'newline) + (eq this-command 'electric-newline-and-maybe-indent) + (string-match-p "return\\|newline\\|send" (symbol-name (or this-command 'unknown))))) + ;; If auto-advance mode is enabled, use the advance function, otherwise just clear + (if claude-code-auto-advance-queue + (claude-code--auto-advance-to-next) + (claude-code--auto-clear-on-ret)))) + +;; Add the hook to set up auto-clearing in Claude buffers +(add-hook 'claude-code-start-hook #'claude-code--setup-auto-clear-hook) + +;;;###autoload +(defun claude-code-toggle-auto-advance-queue () + "Toggle auto-advance queue mode on or off. + +When enabled, pressing enter in a Claude buffer will clear it from the +queue and automatically advance to the next queue entry." + (interactive) + (setq claude-code-auto-advance-queue (not claude-code-auto-advance-queue)) + (message "Claude Code auto-advance queue mode %s" + (if claude-code-auto-advance-queue "enabled" "disabled"))) + +;;;; Hook Integration Setup + +;;;###autoload +(defun claude-code-org-notifications-setup () + "Set up org-mode notifications using the claude-code-event-hook system." + (interactive) + (add-hook 'claude-code-event-hook 'claude-code-org-notification-listener) + (message "Claude Code org-mode notifications configured")) + +;;;###autoload +(defun claude-code-org-notifications-remove () + "Remove org-mode notification listener from claude-code-event-hook." + (interactive) + (remove-hook 'claude-code-event-hook 'claude-code-org-notification-listener) + (message "Claude Code org-mode notifications removed")) + +;;;; Integration + +;; Configure display rule for notification buffer +(add-to-list 'display-buffer-alist + '("^\\*Claude Code Notification\\*$" + (display-buffer-in-side-window) + (side . bottom) + (window-height . 0.1) + (select . nil) + (quit-window . kill))) + +(provide 'claude-code-org-notifications) + +;;; claude-code-org-notifications.el ends here diff --git a/claude-code.el b/claude-code.el index 542622d..b23c244 100644 --- a/claude-code.el +++ b/claude-code.el @@ -350,9 +350,16 @@ for each directory across multiple invocations.") (define-key map (kbd "3") 'claude-code-send-3) (define-key map (kbd "M") 'claude-code-cycle-mode) (define-key map (kbd "o") 'claude-code-send-buffer-file) + (define-key map (kbd "w") 'claude-code-goto-recent-workspace) + (define-key map (kbd "W") 'claude-code-goto-recent-workspace-and-clear) + (define-key map (kbd "]") 'claude-code-queue-next) + (define-key map (kbd "[") 'claude-code-queue-previous) + (define-key map (kbd "D") 'claude-code-queue-skip) + (define-key map (kbd "q") 'claude-code-queue-browse) map) "Keymap for Claude commands.") + ;;;; Transient Menus ;;;###autoload (autoload 'claude-code-transient "claude-code" nil t) (transient-define-prefix claude-code-transient () @@ -388,6 +395,16 @@ for each directory across multiple invocations.") ("1" "Send \"1\"" claude-code-send-1) ("2" "Send \"2\"" claude-code-send-2) ("3" "Send \"3\"" claude-code-send-3) + ] + ["Workspace Navigation" + ("w" "Go to recent workspace" claude-code-goto-recent-workspace) + ("W" "Go to workspace and clear" claude-code-goto-recent-workspace-and-clear) + ] + ["Queue Navigation" + ("]" "Next in queue" claude-code-queue-next) + ("[" "Previous in queue" claude-code-queue-previous) + ("D" "Skip current entry" claude-code-queue-skip) + ("q" "Browse queue" claude-code-queue-browse) ]]) ;;;###autoload (autoload 'claude-code-slash-commands "claude-code" nil t) @@ -477,6 +494,9 @@ Returns the buffer containing the terminal.") (declare-function eat-term-reset "eat" (terminal)) (declare-function eat-term-send-string "eat" (terminal string)) +;; Provide claude-code early (following magit pattern) +(provide 'claude-code) + ;; Helper to ensure eat is loaded (defun claude-code--ensure-eat () "Ensure eat package is loaded." @@ -1202,6 +1222,14 @@ With double prefix ARG (\\[universal-argument] \\[universal-argument]), prompt f ;; Add cleanup hook to remove directory mappings when buffer is killed (add-hook 'kill-buffer-hook #'claude-code--cleanup-directory-mapping nil t) + ;; Add cleanup hook to remove queue entries when buffer is killed + (when (fboundp 'claude-code--cleanup-queue-entries) + (add-hook 'kill-buffer-hook #'claude-code--cleanup-queue-entries nil t)) + + ;; Add buffer to current perspective if persp-mode is active + (when (and (featurep 'persp-mode) (fboundp 'persp-add-buffer)) + (persp-add-buffer buffer)) + ;; run start hooks (run-hooks 'claude-code-start-hook) @@ -1840,7 +1868,12 @@ and managing Claude sessions." :global t :group 'claude-code) -;;;; Provide the feature +;; Conditionally load extension modules (following magit pattern) +(cl-eval-when (load eval) + (let ((notifications-file (expand-file-name "claude-code-org-notifications.el" + (file-name-directory (or load-file-name buffer-file-name))))) + (when (file-exists-p notifications-file) + (load notifications-file nil t)))) (provide 'claude-code) ;;; claude-code.el ends here