summaryrefslogtreecommitdiff
path: root/lisp/agent-shell-diff.el
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-11 08:50:14 +0200
committercashmere <cashmere@autistici.org>2026-07-11 08:50:14 +0200
commitc74c3526fe719ff8127d17f44b67a33772adc334 (patch)
tree7b05d6cad835f8704430ac38c4f22358601fe846 /lisp/agent-shell-diff.el
parentc6579ddf761aaa1c922213310dc1f46347ea06ca (diff)
downloademacs-c74c3526fe719ff8127d17f44b67a33772adc334.tar.gz
2026-07-11T08:50:16+02:00
Diffstat (limited to 'lisp/agent-shell-diff.el')
-rw-r--r--lisp/agent-shell-diff.el268
1 files changed, 268 insertions, 0 deletions
diff --git a/lisp/agent-shell-diff.el b/lisp/agent-shell-diff.el
new file mode 100644
index 0000000..b78bdf1
--- /dev/null
+++ b/lisp/agent-shell-diff.el
@@ -0,0 +1,268 @@
+;;; agent-shell-diff.el --- A quick way to query/display a diff. -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Alvaro Ramirez
+
+;; Author: Alvaro Ramirez https://xenodium.com
+;; URL: https://github.com/xenodium/agent-shell
+
+;; This package is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 3, or (at your option)
+;; any later version.
+
+;; This package is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;;
+;; Report issues at https://github.com/xenodium/agent-shell/issues
+;;
+;; ✨ Please support this work https://github.com/sponsors/xenodium ✨
+
+;;; Code:
+
+(eval-when-compile
+ (require 'cl-lib))
+(require 'ansi-color)
+(require 'diff)
+(require 'diff-mode)
+
+(defcustom agent-shell-diff-delta-command
+ '("delta" "--no-gitconfig" "--color-only")
+ "Command and arguments used to colorize diffs with delta.
+
+Set to nil to disable delta and fall back to the standard `diff-no-select'
+output. delta must be installed and on `exec-path'."
+ :type '(choice (const :tag "Disable delta" nil)
+ (repeat string))
+ :group 'agent-shell)
+
+(defvar-local agent-shell-diff--on-exit nil
+ "Function to call when the diff buffer is killed.
+
+This variable is automatically set by :on-exit from `agent-shell-diff'
+and can be temporarily let-bound to nil to prevent the
+on-exit callback from running when the buffer is killed.")
+
+(defvar-local agent-shell-diff--file nil
+ "Buffer-local file path associated with the diff.")
+
+(defvar-local agent-shell-diff--accept-all-command nil
+ "Buffer-local command to accept all changes in the diff.")
+
+(defvar-local agent-shell-diff--reject-all-command nil
+ "Buffer-local command to reject all changes in the diff.")
+
+(defvar-local agent-shell-diff--used-delta nil
+ "Non-nil when the current buffer's diff was generated using delta.")
+
+(defvar agent-shell-diff-mode-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "n") #'diff-hunk-next)
+ (define-key map (kbd "p") #'diff-hunk-prev)
+ (define-key map (kbd "y") #'agent-shell-diff-accept-all)
+ (define-key map (kbd "C-c C-c") #'agent-shell-diff-reject-all)
+ (define-key map (kbd "f") #'agent-shell-diff-open-file)
+ (define-key map (kbd "q") #'kill-current-buffer)
+ map)
+ "Keymap for `agent-shell-diff-mode'.")
+
+(define-derived-mode agent-shell-diff-mode diff-mode "Agent-Shell-Diff"
+ "Major mode for `agent-shell' diff buffers.
+Derives from `diff-mode'. Provides `agent-shell-diff-accept-all'
+and `agent-shell-diff-reject-all' commands that can be rebound
+via `agent-shell-diff-mode-map'."
+ :group 'agent-shell
+ ;; Don't inherit diff-mode-map (some bindings can be destructive).
+ (set-keymap-parent agent-shell-diff-mode-map nil)
+ (setq buffer-read-only t))
+
+(defun agent-shell-diff-kill-buffer (buffer)
+ "Kill diff BUFFER, suppressing any `agent-shell-diff--on-exit' callback.
+If BUFFER is not live, do nothing."
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (setq agent-shell-diff--on-exit nil))
+ (kill-buffer buffer)))
+
+(defun agent-shell-diff-accept-all ()
+ "Accept all changes in the current diff buffer."
+ (interactive)
+ (if agent-shell-diff--accept-all-command
+ (let ((buf (current-buffer)))
+ (funcall agent-shell-diff--accept-all-command)
+ (when (buffer-live-p buf)
+ (let ((agent-shell-diff--on-exit nil))
+ (kill-buffer buf))))
+ (user-error "No accept command available in this buffer")))
+
+(defun agent-shell-diff-reject-all ()
+ "Reject all changes in the current diff buffer."
+ (interactive)
+ (if agent-shell-diff--reject-all-command
+ (let ((buf (current-buffer)))
+ (when (funcall agent-shell-diff--reject-all-command)
+ (when (buffer-live-p buf)
+ (let ((agent-shell-diff--on-exit nil))
+ (kill-buffer buf)))))
+ (user-error "No reject command available in this buffer")))
+
+(cl-defun agent-shell-diff (&key old new on-exit on-accept on-reject title file)
+ "Display a diff between OLD and NEW strings in a buffer.
+
+Creates a new buffer showing the differences between OLD and NEW
+using `agent-shell-diff-mode'. The buffer is read-only.
+
+When the buffer is killed, calls ON-EXIT with no arguments.
+
+Returns the newly created diff buffer.
+
+Arguments:
+ :OLD - Original string content
+ :NEW - Modified string content
+ :ON-EXIT - Function called with no arguments when buffer is killed
+ :ON-ACCEPT - Command to accept all changes
+ :ON-REJECT - Command to reject all changes
+ :TITLE - Optional title to display in header line
+ :FILE - File path"
+ (let* ((diff-buffer (generate-new-buffer "*agent-shell-diff*"))
+ (calling-window (selected-window))
+ (calling-buffer (current-buffer))
+ (interrupt-key (where-is-internal 'agent-shell-interrupt
+ (current-local-map) t)))
+ (unwind-protect
+ (progn
+ (with-current-buffer diff-buffer
+ (let ((inhibit-read-only t)
+ (diff-mode-read-only nil))
+ (erase-buffer)
+ ;; Set mode before inserting diff so diff-no-select
+ ;; doesn't reset font-lock (see #316).
+ (agent-shell-diff-mode)
+ (agent-shell-diff--insert-diff old new file diff-buffer)
+ ;; Add overlays to hide scary text.
+ (save-excursion
+ (goto-char (point-min))
+ ;; `diff-no-select' adds a command line at the top and
+ ;; "Diff finished." at the bottom. delta output does not,
+ ;; so only strip those when delta was not used.
+ (unless agent-shell-diff--used-delta
+ (delete-region (point) (progn (forward-line 1) (point)))
+ (delete-region (progn (goto-char (point-max)) (forward-line -1) (forward-line 0) (point))
+ (point-max))
+ (goto-char (point-min)))
+ (goto-char (point-min))
+ ;; Hide --- and +++ lines
+ (while (re-search-forward "^\\(---\\|\\+\\+\\+\\).*\n" nil t)
+ (let ((overlay (make-overlay (match-beginning 0) (match-end 0))))
+ (overlay-put overlay 'category 'diff-header)
+ (overlay-put overlay 'display "")
+ (overlay-put overlay 'evaporate t)))
+ ;; Replace @@ lines with "Changes"
+ (goto-char (point-min))
+ (while (re-search-forward "^@@.*@@.*\n" nil t)
+ (let ((overlay (make-overlay (match-beginning 0) (match-end 0)))
+ (face 'diff-hunk-header)) ; or any face you prefer
+ (overlay-put overlay 'category 'diff-header)
+ ;; Intended display is:
+ ;; ╭─────────╮
+ ;; │ changes │
+ ;; ╰─────────╯
+ ;; Using before-string so diff-hunk-next
+ ;; lands on "│" instead of "╭".
+ (overlay-put overlay 'before-string
+ (propertize "\n╭─────────╮\n" 'face face))
+ (overlay-put overlay 'display
+ (propertize "│ changes │\n╰─────────╯\n\n" 'face face))
+ (overlay-put overlay 'evaporate t)))))
+ (goto-char (point-min))
+ (ignore-errors (diff-hunk-next))
+ (setq agent-shell-diff--file file
+ agent-shell-diff--accept-all-command on-accept
+ agent-shell-diff--reject-all-command on-reject)
+ (when on-exit
+ (setq agent-shell-diff--on-exit on-exit)
+ (add-hook 'kill-buffer-hook
+ (lambda ()
+ (when (and agent-shell-diff--on-exit
+ (buffer-live-p calling-buffer))
+ (with-current-buffer calling-buffer
+ (funcall on-exit)))
+ ;; Give focus back to calling buffer.
+ (when (buffer-live-p calling-buffer)
+ (ignore-errors
+ (when (window-live-p calling-window)
+ (unless (eq (window-buffer calling-window) calling-buffer)
+ (set-window-buffer calling-window calling-buffer))
+ (select-window calling-window)))))
+ nil t))
+ (let ((map (copy-keymap agent-shell-diff-mode-map)))
+ (when (and interrupt-key
+ (not (lookup-key map interrupt-key)))
+ (define-key map interrupt-key #'agent-shell-diff-reject-all))
+ (use-local-map map))
+ (setq header-line-format
+ (substitute-command-keys
+ (concat
+ " "
+ (when title
+ (concat (propertize title 'face 'mode-line-emphasis) " "))
+ "\\[diff-hunk-next] next hunk "
+ "\\[diff-hunk-prev] previous hunk "
+ "\\[agent-shell-diff-accept-all] accept "
+ "\\[agent-shell-diff-reject-all] reject "
+ "\\[agent-shell-diff-open-file] open "
+ "\\[kill-current-buffer] quit"))))
+ diff-buffer)
+ (pop-to-buffer diff-buffer '((display-buffer-use-some-window
+ display-buffer-same-window))))))
+
+(defun agent-shell-diff-open-file ()
+ "Open the file associated with the current diff buffer."
+ (interactive)
+ (if agent-shell-diff--file
+ (find-file agent-shell-diff--file)
+ (user-error "No file associated with this diff buffer")))
+
+(defun agent-shell-diff--insert-diff (old new file buf)
+ "Insert diff from FILE between OLD and NEW strings in buffer BUF.
+
+When `agent-shell-diff-delta-command' is non-nil and delta is available
+on `exec-path', pipe the unified diff through delta for syntax
+highlighting; otherwise fall back to `diff-no-select'."
+ (let* ((suffix (format ".%s" (file-name-extension file)))
+ (old-file (make-temp-file "old" nil suffix))
+ (new-file (make-temp-file "new" nil suffix))
+ (use-delta (and agent-shell-diff-delta-command
+ (executable-find (car agent-shell-diff-delta-command)))))
+ (with-temp-file old-file (insert old))
+ (with-temp-file new-file (insert new))
+ (with-current-buffer buf
+ (setq agent-shell-diff--used-delta use-delta)
+ (if use-delta
+ (let ((coding-system-for-read 'utf-8)
+ (coding-system-for-write 'utf-8)
+ (command (concat "diff -U3 "
+ (shell-quote-argument old-file)
+ " "
+ (shell-quote-argument new-file)
+ " | "
+ (mapconcat #'shell-quote-argument
+ agent-shell-diff-delta-command
+ " "))))
+ (call-process-shell-command command nil t nil)
+ ;; `ansi-color-apply-on-region' can drop properties in some
+ ;; Emacs builds, so apply to the string and re-insert.
+ (let ((colored (ansi-color-apply (buffer-string))))
+ (erase-buffer)
+ (insert colored)))
+ (diff-no-select old-file new-file "-U3" t buf)))))
+
+(provide 'agent-shell-diff)
+
+;;; agent-shell-diff.el ends here