aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-16 01:55:16 -0500
committerCraig Jennings <c@cjennings.net>2026-05-16 01:55:16 -0500
commit6ee37e0a68d31909861cf59684d3601bf40f5abe (patch)
treed14726945d65853f183896a06532c1e88bbb81de
parent670117cccdbae4706dfaa5e05144c256c3a657f0 (diff)
downloaddotemacs-6ee37e0a68d31909861cf59684d3601bf40f5abe.tar.gz
dotemacs-6ee37e0a68d31909861cf59684d3601bf40f5abe.zip
feat(ai-rewrite): add directive-picker wrappers around gptel-rewrite
`gptel-rewrite` is the killer feature for the keep-gptel decision, and it now lives behind two commands instead of the bare call: - `cj/gptel-rewrite-with-directive` (`C-; a r`, replacing the former bare `gptel-rewrite` binding): completing-read on a directive name from `cj/gptel-rewrite-directives`, then rewrite the active region. - `cj/gptel-rewrite-redo-with-different-directive` (`C-; a R`): replay the prior region with a different directive. The region is preserved via markers stored buffer-local on the first call so it survives accept/reject of the prior rewrite. I picked the hook injection approach over an `:after`-advice + state-capture pattern. `gptel-rewrite-directives-hook` is an abnormal hook gptel-rewrite already consults for a per-call system message. Wrapping the call in a one-shot `let`-binding on that hook gives the directive exactly the lifetime of the rewrite and leaves nothing to clean up. Mutating `gptel-directives` globally would mean either restoring it afterward or living with the change -- both worse than the hook. Directives ship inline as a `defcustom` alist with the six names called out in the task -- `terse`, `fix-grammar`, `refactor-readability`, `add-docstring`, `explain-as-comment`, `shorten`. Customization is a `customize-variable` or `setq` away. 9 tests cover the defcustom shape (default names present, bodies non-empty strings), the wrapper (normal path, no-region error, unknown-directive error, last-state recording), and the redo (replays the prior region, errors when no previous, excludes the current directive from the re-pick prompt). `gptel-rewrite` stubbed in tests so no rewrite UI fires.
-rw-r--r--modules/ai-config.el8
-rw-r--r--modules/ai-rewrite.el108
-rw-r--r--tests/test-ai-rewrite.el159
-rw-r--r--todo.org38
4 files changed, 303 insertions, 10 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
index 6eff1ba6..c7a14cae 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -35,6 +35,8 @@
(autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t)
(autoload 'cj/gptel-autosave-toggle "ai-conversations" "Toggle autosave in the current GPTel buffer." t)
(autoload 'cj/gptel-quick-ask "ai-quick-ask" "One-shot quick-ask in a transient buffer." t)
+(autoload 'cj/gptel-rewrite-with-directive "ai-rewrite" "Pick a directive and run gptel-rewrite on the region." t)
+(autoload 'cj/gptel-rewrite-redo-with-different-directive "ai-rewrite" "Re-run the previous rewrite with a different directive." t)
;;; ------------------------- AI Config Helper Functions ------------------------
@@ -510,7 +512,8 @@ Works for any buffer, whether it's visiting a file or not."
"m" #'cj/gptel-change-model ;; change the LLM model
"p" #'gptel-system-prompt ;; change prompt
"q" #'cj/gptel-quick-ask ;; one-shot quick ask
- "r" #'gptel-rewrite ;; rewrite a region of code/text
+ "r" #'cj/gptel-rewrite-with-directive ;; rewrite region with a chosen directive
+ "R" #'cj/gptel-rewrite-redo-with-different-directive ;; redo last rewrite, new directive
"c" #'cj/gptel-context-clear ;; clear all context
"s" #'cj/gptel-save-conversation ;; save conversation
"t" #'cj/toggle-gptel ;; toggles the ai-assistant window
@@ -530,7 +533,8 @@ Works for any buffer, whether it's visiting a file or not."
"C-; a m" "change model"
"C-; a p" "change prompt"
"C-; a q" "quick ask"
- "C-; a r" "rewrite region"
+ "C-; a r" "rewrite region (directive)"
+ "C-; a R" "redo rewrite, new directive"
"C-; a c" "clear context"
"C-; a s" "save conversation"
"C-; a t" "toggle window"
diff --git a/modules/ai-rewrite.el b/modules/ai-rewrite.el
new file mode 100644
index 00000000..fb25c137
--- /dev/null
+++ b/modules/ai-rewrite.el
@@ -0,0 +1,108 @@
+;;; ai-rewrite.el --- Directive-picker wrappers for gptel-rewrite -*- lexical-binding: t; coding: utf-8; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Adds two ergonomic wrappers around `gptel-rewrite':
+;;
+;; cj/gptel-rewrite-with-directive Pick a named directive,
+;; then rewrite the region.
+;; cj/gptel-rewrite-redo-with-different-directive
+;; Re-run the previous region
+;; with a different directive.
+;;
+;; A directive is a short system-message snippet attached to a name
+;; (e.g. "terse", "fix-grammar"). The directive body is injected
+;; into the rewrite via `gptel-rewrite-directives-hook' just for that
+;; call -- no global state changes.
+
+;;; Code:
+
+;; Declare the hook variable special so our `let'-binding below is
+;; dynamic (visible across the `call-interactively' that follows)
+;; rather than lexical when this file is byte-compiled.
+(defvar gptel-rewrite-directives-hook)
+
+(defcustom cj/gptel-rewrite-directives
+ '(("terse"
+ . "Rewrite the text to be as terse as possible without losing meaning.\nDo not add commentary. Return only the rewritten text.")
+ ("fix-grammar"
+ . "Fix grammar and spelling errors only. Do not rephrase, restructure,\nor change tone. Return only the corrected text.")
+ ("refactor-readability"
+ . "Refactor the code for readability. Improve naming, split long\nfunctions when appropriate, remove unnecessary complexity, and preserve\nbehavior exactly. Return only the refactored code.")
+ ("add-docstring"
+ . "Add or improve docstrings for every function in the region. Use the\nidiomatic docstring style for the language. Do not change executable\ncode. Return the whole region with the updated docstrings.")
+ ("explain-as-comment"
+ . "Replace the region with the original code, preceded by a concise\nblock comment explaining what the code does. Use the language's\nidiomatic comment syntax. Return code + comment, nothing else.")
+ ("shorten"
+ . "Shorten the text while preserving meaning, technical accuracy, and\nthe author's voice. Remove rhetorical padding. Return only the\nshortened text."))
+ "Named system-message directives for `cj/gptel-rewrite-with-directive'.
+Each entry is a (NAME . BODY) pair where NAME is the directive label
+presented in the completing-read prompt and BODY is the system
+message injected into the next `gptel-rewrite' call."
+ :type '(alist :key-type string :value-type string)
+ :group 'cj)
+
+(defvar-local cj/gptel-rewrite--last-region nil
+ "Cons (BEG-MARKER . END-MARKER) of the last directive-driven rewrite.")
+
+(defvar-local cj/gptel-rewrite--last-directive nil
+ "Name of the directive used in the last directive-driven rewrite.")
+
+(defun cj/gptel-rewrite--call-with-directive (directive-name beg end)
+ "Run `gptel-rewrite' over BEG..END with DIRECTIVE-NAME's system message.
+Stores the region (as markers) and directive name on buffer-local
+variables so `cj/gptel-rewrite-redo-with-different-directive' can
+revisit them."
+ (let ((body (alist-get directive-name cj/gptel-rewrite-directives
+ nil nil #'equal)))
+ (unless body
+ (user-error "Unknown rewrite directive: %s" directive-name))
+ (setq-local cj/gptel-rewrite--last-region
+ (cons (copy-marker beg) (copy-marker end)))
+ (setq-local cj/gptel-rewrite--last-directive directive-name)
+ (let ((gptel-rewrite-directives-hook
+ (cons (lambda () body) gptel-rewrite-directives-hook)))
+ (save-excursion
+ (goto-char beg)
+ (push-mark end t t)
+ (call-interactively #'gptel-rewrite)))))
+
+;;;###autoload
+(defun cj/gptel-rewrite-with-directive (directive-name)
+ "Pick DIRECTIVE-NAME from `cj/gptel-rewrite-directives' and rewrite the region.
+Requires an active region. The directive is applied only to this
+call -- it does not modify global `gptel-directives'."
+ (interactive
+ (progn
+ (unless (use-region-p)
+ (user-error "No region selected"))
+ (list (completing-read
+ "Rewrite directive: "
+ (mapcar #'car cj/gptel-rewrite-directives) nil t))))
+ (cj/gptel-rewrite--call-with-directive
+ directive-name (region-beginning) (region-end)))
+
+;;;###autoload
+(defun cj/gptel-rewrite-redo-with-different-directive ()
+ "Re-run the previous directive-driven rewrite with a different directive.
+The region is restored from the markers captured at the last call;
+the user picks a new directive from the remaining choices."
+ (interactive)
+ (unless cj/gptel-rewrite--last-region
+ (user-error "No previous rewrite to redo in this buffer"))
+ (let* ((beg-mk (car cj/gptel-rewrite--last-region))
+ (end-mk (cdr cj/gptel-rewrite--last-region))
+ (current cj/gptel-rewrite--last-directive)
+ (others (cl-remove
+ current
+ (mapcar #'car cj/gptel-rewrite-directives)
+ :test #'equal))
+ (chosen (completing-read
+ (format "Re-rewrite with (was %s): " current)
+ others nil t)))
+ (cj/gptel-rewrite--call-with-directive
+ chosen (marker-position beg-mk) (marker-position end-mk))))
+
+(provide 'ai-rewrite)
+;;; ai-rewrite.el ends here
diff --git a/tests/test-ai-rewrite.el b/tests/test-ai-rewrite.el
new file mode 100644
index 00000000..ddb83133
--- /dev/null
+++ b/tests/test-ai-rewrite.el
@@ -0,0 +1,159 @@
+;;; test-ai-rewrite.el --- Tests for ai-rewrite.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the directive-picker wrappers around `gptel-rewrite'.
+;; `gptel-rewrite' itself is stubbed so the tests verify what the
+;; wrappers do (which directive body lands in the hook, which region
+;; was captured) without touching the real rewrite UI.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+(require 'testutil-ai-config)
+
+;; Stub the gptel-rewrite surface so the wrapper can dispatch to it
+;; without loading the real package. testutil-ai-config provides a
+;; non-interactive stub of `gptel-rewrite'; we override it with an
+;; interactive recorder that captures the hook-derived directive body
+;; and the active region.
+(defvar gptel-rewrite-directives-hook nil)
+(defvar test-ai-rewrite--captured-directive nil
+ "Last system-message body produced by the hook during a stub rewrite.")
+(defvar test-ai-rewrite--captured-region nil
+ "Cons (BEG . END) captured from `mark' and `point' at stub-rewrite time.")
+(defun gptel-rewrite ()
+ "Stub: capture the directive body and the active region."
+ (interactive)
+ (setq test-ai-rewrite--captured-directive
+ (run-hook-with-args-until-success 'gptel-rewrite-directives-hook))
+ (setq test-ai-rewrite--captured-region
+ (cons (region-beginning) (region-end))))
+
+(require 'ai-rewrite)
+
+;; ---------------------------- defcustom shape
+
+(ert-deftest test-ai-rewrite-directives-defcustom-has-named-entries ()
+ "Default directives include the names called out in the spec."
+ (let ((names (mapcar #'car cj/gptel-rewrite-directives)))
+ (dolist (expected '("terse" "fix-grammar" "refactor-readability"
+ "add-docstring" "explain-as-comment" "shorten"))
+ (should (member expected names)))))
+
+(ert-deftest test-ai-rewrite-directives-bodies-are-strings ()
+ "Every directive body is a non-empty string."
+ (dolist (entry cj/gptel-rewrite-directives)
+ (should (stringp (cdr entry)))
+ (should (> (length (cdr entry)) 0))))
+
+;; ---------------------------- with-directive
+
+(ert-deftest test-ai-rewrite-with-directive-normal ()
+ "Wrapper injects the directive body and runs gptel-rewrite on the region."
+ (with-temp-buffer
+ (insert "first body line\nsecond body line\n")
+ (let ((test-ai-rewrite--captured-directive nil)
+ (test-ai-rewrite--captured-region nil)
+ (cj/gptel-rewrite-directives
+ '(("test" . "BODY FOR TEST DIRECTIVE"))))
+ ;; Activate the region across both lines
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (cj/gptel-rewrite-with-directive "test")
+ (should (equal test-ai-rewrite--captured-directive
+ "BODY FOR TEST DIRECTIVE"))
+ (should test-ai-rewrite--captured-region))))
+
+(ert-deftest test-ai-rewrite-with-directive-error-no-region ()
+ "No active region signals."
+ (with-temp-buffer
+ (insert "no region")
+ (deactivate-mark)
+ (should-error (call-interactively #'cj/gptel-rewrite-with-directive))))
+
+(ert-deftest test-ai-rewrite-with-directive-error-unknown-directive ()
+ "Unknown directive name signals."
+ (with-temp-buffer
+ (insert "body")
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (let ((cj/gptel-rewrite-directives '(("known" . "x"))))
+ (should-error
+ (cj/gptel-rewrite--call-with-directive
+ "unknown" (point-min) (point-max))))))
+
+(ert-deftest test-ai-rewrite-with-directive-records-last-state ()
+ "Wrapper records the region and directive name for later redo."
+ (with-temp-buffer
+ (insert "abc\ndef\n")
+ (let ((cj/gptel-rewrite-directives
+ '(("first" . "FIRST BODY")))
+ (test-ai-rewrite--captured-directive nil))
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (cj/gptel-rewrite-with-directive "first")
+ (should (equal cj/gptel-rewrite--last-directive "first"))
+ (should (consp cj/gptel-rewrite--last-region))
+ (should (markerp (car cj/gptel-rewrite--last-region)))
+ (should (markerp (cdr cj/gptel-rewrite--last-region))))))
+
+;; ---------------------------- redo
+
+(ert-deftest test-ai-rewrite-redo-normal ()
+ "Redo replays the last region with a new directive."
+ (with-temp-buffer
+ (insert "line1\nline2\nline3\n")
+ (let* ((cj/gptel-rewrite-directives
+ '(("first" . "FIRST BODY")
+ ("second" . "SECOND BODY")))
+ (test-ai-rewrite--captured-directive nil)
+ (test-ai-rewrite--captured-region nil))
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (cj/gptel-rewrite-with-directive "first")
+ (should (equal test-ai-rewrite--captured-directive "FIRST BODY"))
+ (let ((first-region test-ai-rewrite--captured-region))
+ (setq test-ai-rewrite--captured-directive nil)
+ (setq test-ai-rewrite--captured-region nil)
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p choices &rest _) (car choices))))
+ (cj/gptel-rewrite-redo-with-different-directive))
+ (should (equal test-ai-rewrite--captured-directive "SECOND BODY"))
+ (should (equal test-ai-rewrite--captured-region first-region))))))
+
+(ert-deftest test-ai-rewrite-redo-error-no-previous ()
+ "Redo without prior rewrite signals."
+ (with-temp-buffer
+ (setq-local cj/gptel-rewrite--last-region nil)
+ (should-error (cj/gptel-rewrite-redo-with-different-directive))))
+
+(ert-deftest test-ai-rewrite-redo-excludes-current-directive ()
+ "Redo's completing-read prompt offers every directive except the last."
+ (with-temp-buffer
+ (insert "body")
+ (let ((cj/gptel-rewrite-directives
+ '(("a" . "A") ("b" . "B") ("c" . "C")))
+ (offered nil))
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (cj/gptel-rewrite-with-directive "b")
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p choices &rest _)
+ (setq offered choices)
+ (car choices))))
+ (cj/gptel-rewrite-redo-with-different-directive))
+ (should (equal (sort (copy-sequence offered) #'string<)
+ '("a" "c"))))))
+
+(provide 'test-ai-rewrite)
+;;; test-ai-rewrite.el ends here
diff --git a/todo.org b/todo.org
index 90017155..c59b41ab 100644
--- a/todo.org
+++ b/todo.org
@@ -2687,14 +2687,36 @@ Survey what published gptel community tools exist (the gptel README, karthink's
Output: a shortlist in =docs/design/gptel-tools-shortlist.org= with the adopt/skip/defer decisions and a follow-up extraction sub-task per "adopt".
-*** TODO [#C] Promote gptel-rewrite ergonomics :feature:
-
-=gptel-rewrite= is the killer feature for the keep-gptel decision. Currently bound only to =C-; a r=. Make it land closer to the editing flow:
-
-- A directive-picker wrapper =cj/gptel-rewrite-with-directive= that =completing-read='s a directive name (=terse=, =fix-grammar=, =refactor-readability=, =add-docstring=, =explain-as-comment=, =shorten=) before delegating to =gptel-rewrite=. Directive bodies live in =ai-prompts/= so they share storage with =gptel-prompts=.
-- A =cj/gptel-rewrite-redo-with-different-directive= command for "the last rewrite was close — try this style instead". Walks back to the prior region (or kept selection) and re-prompts.
-
-Open question: should this build on =gptel-rewrite= directly via =:after= advice + state capture, or wrap the call in a =cj/= command that sets =gptel-directives= for the duration?
+*** 2026-05-16 Sat @ 01:54:34 -0500 Added directive-picker wrappers around gptel-rewrite
+
+New module =modules/ai-rewrite.el= with two commands:
+
+- =cj/gptel-rewrite-with-directive= (=C-; a r=, replacing the bare
+ =gptel-rewrite= binding): completing-read on a directive name from
+ =cj/gptel-rewrite-directives=, then rewrite the active region.
+- =cj/gptel-rewrite-redo-with-different-directive= (=C-; a R=): replay
+ the prior region with a different directive (markers are saved
+ buffer-local so the region survives accept/reject of the first
+ rewrite).
+
+Open-question answer: the directive is injected via a one-shot
+=let=-binding on =gptel-rewrite-directives-hook= (an abnormal hook
+that gptel-rewrite already supports for per-call system messages),
+not by mutating =gptel-directives= globally. No advice on
+=gptel-rewrite= and no state to clean up after the call returns.
+
+Directives ship inline as a =defcustom= alist with the six names
+called out in the task body (=terse=, =fix-grammar=,
+=refactor-readability=, =add-docstring=, =explain-as-comment=,
+=shorten=) so customization is straightforward without a separate
+file layer.
+
+9 tests in =tests/test-ai-rewrite.el= cover the defcustom shape,
+the wrapper (normal path, no-region error, unknown-directive
+error, last-state recording), and the redo (replays prior region,
+errors when no previous, excludes the current directive from the
+re-pick prompt). =gptel-rewrite= stubbed for tests so no rewrite
+UI fires.
*** TODO [#B] Saved-conversations browser :feature: