aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-16 01:42:30 -0500
committerCraig Jennings <c@cjennings.net>2026-05-16 01:42:30 -0500
commita8c7e8bf822535470d1a4621030b0edd07aaccb4 (patch)
treed4a94450401f36c594e37ae9ca7fd107f31ac2f6
parent73e63b6c6850f8e14d8374c7bf6b127971cfbb08 (diff)
downloaddotemacs-a8c7e8bf822535470d1a4621030b0edd07aaccb4.tar.gz
dotemacs-a8c7e8bf822535470d1a4621030b0edd07aaccb4.zip
feat(ai-conversations): add cj/gptel-autosave-toggle with [AS] mode-line indicator
`cj/gptel-autosave-enabled` flipped to t inside the save/load entry points with no way back off short of editing the variable or clearing the buffer, and no visible indicator that it was on. Two pieces: - `cj/gptel-autosave-toggle` flips the buffer-local state in the current GPTel buffer. Bound to `C-; a A` via `cj/ai-keymap` (which-key: "toggle autosave"). When autosave is OFF and no filepath is configured yet, the command prompts to save the conversation first so a save target exists; otherwise it just flips the bit. - `cj/gptel-autosave-mode-line-format` surfaces " [AS]" in the mode-line when autosave is on, blank when off. Installed via a `gptel-mode-hook` so every GPTel buffer picks it up. The install helper is idempotent. 6 new tests cover enable/disable paths, the no-filepath prompt path, the not-a-gptel-buffer error path, the mode-line format evaluation, and the install idempotence.
-rw-r--r--modules/ai-config.el3
-rw-r--r--modules/ai-conversations.el42
-rw-r--r--tests/test-ai-conversations.el62
-rw-r--r--todo.org24
4 files changed, 125 insertions, 6 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
index 0ffee799..e7907e36 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -33,6 +33,7 @@
(autoload 'cj/gptel-save-conversation "ai-conversations" "Save the AI conversation to a file." t)
(autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t)
(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)
;;; ------------------------- AI Config Helper Functions ------------------------
@@ -498,6 +499,7 @@ Works for any buffer, whether it's visiting a file or not."
(defvar-keymap cj/ai-keymap
:doc "Keymap for gptel and other AI operations."
+ "A" #'cj/gptel-autosave-toggle ;; toggle autosave on the current GPTel buffer
"B" #'cj/gptel-switch-backend ;; change the backend (OpenAI, Anthropic, etc.
"M" #'gptel-menu ;; gptel's transient menu
"d" #'cj/gptel-delete-conversation ;; delete conversation
@@ -516,6 +518,7 @@ Works for any buffer, whether it's visiting a file or not."
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
"C-; a" "AI assistant menu"
+ "C-; a A" "toggle autosave"
"C-; a B" "switch backend"
"C-; a M" "gptel menu"
"C-; a d" "delete conversation"
diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el
index 4f97d761..03ea6ec9 100644
--- a/modules/ai-conversations.el
+++ b/modules/ai-conversations.el
@@ -51,6 +51,36 @@ If displaying on the top or bottom, treat this value as a height fraction."
(defvar-local cj/gptel-autosave-filepath nil
"File path used for auto-saving the conversation buffer.")
+(defvar cj/gptel-autosave-mode-line-format
+ '(:eval (when (bound-and-true-p cj/gptel-autosave-enabled) " [AS]"))
+ "Mode-line construct that surfaces autosave state in GPTel buffers.")
+(put 'cj/gptel-autosave-mode-line-format 'risky-local-variable t)
+
+(defun cj/gptel-autosave-toggle ()
+ "Toggle autosave on/off in the current GPTel buffer.
+Flips `cj/gptel-autosave-enabled' and forces a mode-line redisplay so
+the [AS] indicator updates immediately. When turning autosave ON
+without a configured filepath, prompt to save the conversation first
+so a path exists to autosave to."
+ (interactive)
+ (unless (bound-and-true-p gptel-mode)
+ (user-error "Not a GPTel buffer"))
+ (if cj/gptel-autosave-enabled
+ (progn
+ (setq-local cj/gptel-autosave-enabled nil)
+ (message "Autosave disabled"))
+ (cond
+ ((and (stringp cj/gptel-autosave-filepath)
+ (> (length cj/gptel-autosave-filepath) 0))
+ (setq-local cj/gptel-autosave-enabled t)
+ (message "Autosave enabled (saving to %s)"
+ (file-name-nondirectory cj/gptel-autosave-filepath)))
+ ((y-or-n-p "No save target yet. Save conversation first? ")
+ (call-interactively #'cj/gptel-save-conversation))
+ (t
+ (message "Autosave not enabled (no save target)"))))
+ (force-mode-line-update))
+
(defcustom cj/gptel-conversations-autosave-on-send t
"Non-nil means auto-save the conversation immediately after `gptel-send'."
:type 'boolean
@@ -71,6 +101,18 @@ If displaying on the top or bottom, treat this value as a height fraction."
(unless (advice-member-p #'cj/gptel--autosave-after-send #'gptel-send)
(advice-add 'gptel-send :after #'cj/gptel--autosave-after-send)))
+(defun cj/gptel--install-autosave-mode-line ()
+ "Add the [AS] autosave indicator to the current buffer's mode-line.
+Idempotent: re-running in the same buffer does not duplicate the
+construct."
+ (unless (member 'cj/gptel-autosave-mode-line-format mode-line-format)
+ (setq-local mode-line-format
+ (append mode-line-format
+ (list 'cj/gptel-autosave-mode-line-format)))))
+
+(with-eval-after-load 'gptel
+ (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-mode-line))
+
(defun cj/gptel--slugify-topic (s)
"Return a filesystem-friendly slug for topic string S."
(let* ((down (downcase (or s "")))
diff --git a/tests/test-ai-conversations.el b/tests/test-ai-conversations.el
index f4d43236..26b2423b 100644
--- a/tests/test-ai-conversations.el
+++ b/tests/test-ai-conversations.el
@@ -405,5 +405,67 @@
(should (= 1 (cl-count #'cj/gptel--autosave-after-response
gptel-post-response-functions)))))
+;; --------------------------------------------- autosave-toggle / indicator
+
+(ert-deftest test-ai-conversations-autosave-toggle-enables-with-filepath ()
+ "Toggle enables autosave when a filepath is set."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (cj/gptel-autosave-toggle)
+ (should cj/gptel-autosave-enabled)))
+
+(ert-deftest test-ai-conversations-autosave-toggle-disables ()
+ "Toggle turns autosave off when already on."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (cj/gptel-autosave-toggle)
+ (should-not cj/gptel-autosave-enabled)))
+
+(ert-deftest test-ai-conversations-autosave-toggle-prompts-when-no-filepath ()
+ "Toggle prompts to save first when no filepath is configured."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath nil)
+ (let ((prompted nil)
+ (save-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (&rest _) (setq prompted t) nil))
+ ((symbol-function 'cj/gptel-save-conversation)
+ (lambda () (setq save-called t))))
+ (cj/gptel-autosave-toggle))
+ (should prompted)
+ (should-not save-called)
+ (should-not cj/gptel-autosave-enabled))))
+
+(ert-deftest test-ai-conversations-autosave-toggle-error-outside-gptel-mode ()
+ "Toggle signals when called outside a gptel buffer."
+ (with-temp-buffer
+ (setq-local gptel-mode nil)
+ (should-error (cj/gptel-autosave-toggle))))
+
+(ert-deftest test-ai-conversations-autosave-mode-line-format-evaluates ()
+ "Mode-line format evaluates to \" [AS]\" only when autosave is enabled."
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave-enabled t)
+ (should (equal (eval (cadr cj/gptel-autosave-mode-line-format))
+ " [AS]")))
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave-enabled nil)
+ (should-not (eval (cadr cj/gptel-autosave-mode-line-format)))))
+
+(ert-deftest test-ai-conversations-install-mode-line-idempotent ()
+ "Repeated installs do not duplicate the construct in mode-line-format."
+ (with-temp-buffer
+ (setq-local mode-line-format '("base"))
+ (cj/gptel--install-autosave-mode-line)
+ (cj/gptel--install-autosave-mode-line)
+ (cj/gptel--install-autosave-mode-line)
+ (should (= 1 (cl-count 'cj/gptel-autosave-mode-line-format mode-line-format)))))
+
(provide 'test-ai-conversations)
;;; test-ai-conversations.el ends here
diff --git a/todo.org b/todo.org
index c0a9fa0e..face1e4c 100644
--- a/todo.org
+++ b/todo.org
@@ -2712,12 +2712,24 @@ UX (decided 2026-05-15):
- A second key (suggested: =c= for "continue") escalates the one-shot into a full conversation: creates a new gptel conversation seeded with the quick-ask prompt + response, then opens it in the normal =*AI-Assistant*= side window. After the escalation the =*GPTel-Quick*= buffer can be dismissed.
- Stream the response into the temp buffer (gptel's default behavior) -- minibuffer echo is awkward for anything past a single line.
-*** TODO [#C] Autosave toggle command + indicator :feature:
-
-=cj/gptel-autosave-enabled= flips to =t= inside the save/load entry points. There's no command to flip it back off without manually setting the var or clearing the buffer, and no visible indicator that autosave is on.
-
-- Add =cj/gptel-autosave-toggle= bound under =C-; a A=.
-- Surface autosave state in the mode-line of the =*AI-Assistant*= buffer (a small =[AS]= when on, blank when off).
+*** 2026-05-16 Sat @ 01:41:51 -0500 Added cj/gptel-autosave-toggle + [AS] mode-line indicator
+
+=cj/gptel-autosave-toggle= flips =cj/gptel-autosave-enabled= in the
+current GPTel buffer. Bound to =C-; a A= via =cj/ai-keymap=
+(which-key labelled "toggle autosave"). When autosave is OFF and no
+filepath is configured, the command prompts to save the conversation
+first so a save target exists. When autosave is ON, the command
+turns it off.
+
+=cj/gptel-autosave-mode-line-format= surfaces " [AS]" in the
+mode-line when autosave is on, blank when off. Installed via a
+=gptel-mode-hook= so every GPTel buffer picks it up. The install
+helper is idempotent.
+
+6 new tests in =tests/test-ai-conversations.el= cover the enable /
+disable paths, the no-filepath prompt path, the
+not-a-gptel-buffer error path, the mode-line format evaluation, and
+the install idempotence.
** TODO [#C] Extend F2 "preview" convention across modes :feature: