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 d47284f..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,6 +306,134 @@ For Windows, you can use PowerShell to create toast notifications: *Note: Linux and Windows examples are untested. Feedback and improvements are welcome!* +### Enhanced Notification System + +The enhanced notification system provides smart, context-aware notifications with queue management: + +- **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 + +### Org Mode Task Tracking + +claude-code.el includes an optional Org mode integration that automatically tracks completed Claude tasks in a persistent log: + +#### 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 + +To enable Org mode notifications, add this to your configuration: + +```elisp +;; Load the org notifications system +(require 'claude-code-org-notifications) + +;; Set up the hook listener to receive events +(claude-code-org-notifications-setup) + +;; Configure Claude Code CLI hooks in settings.json (this will set up the CLI side) +(claude-code-setup-hooks) +``` + +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. + +#### Manual Hook Configuration + +If you prefer to manually configure hooks or already have a settings.json file, you can call: + +```elisp +(claude-code-setup-hooks) +``` + +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: + +- **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) +``` + +Or toggle it interactively: + +```elisp +M-x claude-code-toggle-auto-advance-queue +``` + +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 - **Paste images**: Use `C-v` to paste images into the Claude window. Note that on macOS, this is `Control-v`, not `Command-v`. @@ -302,19 +446,19 @@ For Windows, you can use PowerShell to create toast notifications: (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 @@ -325,15 +469,15 @@ For Windows, you can use PowerShell to create toast notifications: (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 @@ -341,12 +485,12 @@ For Windows, you can use PowerShell to create toast notifications: ;; '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 @@ -356,9 +500,9 @@ For Windows, you can use PowerShell to create toast notifications: (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) @@ -374,6 +518,12 @@ For Windows, you can use PowerShell to create toast notifications: ;; 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 @@ -483,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 @@ -524,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 9f892bf..b23c244 100644 --- a/claude-code.el +++ b/claude-code.el @@ -49,6 +49,11 @@ :type 'hook :group 'claude-code) +(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.") + (defcustom claude-code-startup-delay 0.1 "Delay in seconds after starting Claude before displaying buffer. @@ -303,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. @@ -342,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 () @@ -380,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) @@ -469,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." @@ -485,7 +513,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 +704,8 @@ SWITCHES are optional command-line arguments for PROGRAM." (let* ((vterm-shell (if switches (concat program " " (mapconcat #'identity switches " ")) program)) + (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 @@ -1190,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) @@ -1359,6 +1399,20 @@ MESSAGE is the notification body." (claude-code--pulse-modeline) (message "%s: %s" title 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. @@ -1699,10 +1753,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)))) @@ -1814,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 diff --git a/examples/hooks/claude-code-hook-examples.el b/examples/hooks/claude-code-hook-examples.el new file mode 100644 index 0000000..0f10c1e --- /dev/null +++ b/examples/hooks/claude-code-hook-examples.el @@ -0,0 +1,248 @@ +;;; 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. +;; 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: + +;;;; Basic Hook Listeners + +;; 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-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)) + (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-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)) + (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-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)) + (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-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)) + (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-event-hook 'my-claude-notification-listener) + (message "Basic Claude hooks configured")) + +(defun setup-claude-hooks-advanced () + "Set up advanced Claude hook handling with multiple listeners." + (interactive) + ;; 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-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-event-hook nil) + (message "All Claude hooks removed")) + +(defun list-claude-hooks () + "Show currently configured Claude hook handlers." + (interactive) + (if claude-code-event-hook + (message "Claude hooks: %s" + (mapconcat (lambda (f) (symbol-name f)) claude-code-event-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: + +;;;; Basic Configuration (JSON data only): +;; Use this with the basic listeners (my-claude-notification-listener, etc.) +;; +;; { +;; "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)\"" +;; } +;; ] +;; } +;; ] +;; } +;; } + +;;;; 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 new file mode 100644 index 0000000..2862b74 --- /dev/null +++ b/examples/hooks/example_settings.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [], + "deny": [] + }, + "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)\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "emacsclient --eval \"(claude-code-handle-hook 'pre-tool-use \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "emacsclient --eval \"(claude-code-handle-hook 'post-tool-use \\\"$CLAUDE_BUFFER_NAME\\\")\" \"$(cat)\"" + } + ] + } + ] + } +}