diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-16 01:42:30 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-16 01:42:30 -0500 |
| commit | a8c7e8bf822535470d1a4621030b0edd07aaccb4 (patch) | |
| tree | d4a94450401f36c594e37ae9ca7fd107f31ac2f6 | |
| parent | 73e63b6c6850f8e14d8374c7bf6b127971cfbb08 (diff) | |
| download | dotemacs-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.el | 3 | ||||
| -rw-r--r-- | modules/ai-conversations.el | 42 | ||||
| -rw-r--r-- | tests/test-ai-conversations.el | 62 | ||||
| -rw-r--r-- | todo.org | 24 |
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 @@ -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: |
