summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-16 17:39:56 -0500
committerCraig Jennings <c@cjennings.net>2026-05-16 17:39:56 -0500
commit1fef9aed248aaf7586950475aa22849e413f1c04 (patch)
treecd7b101163e445d8eab6134b61841022afdaeb5d
parent43022b56569717f28fa16284f7092f2bbe0830ad (diff)
downloaddotemacs-1fef9aed248aaf7586950475aa22849e413f1c04.tar.gz
dotemacs-1fef9aed248aaf7586950475aa22849e413f1c04.zip
feat(ai-conversations): autosave on a periodic timer
The existing autosave only fired after gptel-send returned, so a conversation paused mid-thought wasn't on disk if Emacs crashed. I added a buffer-local repeating timer that calls cj/gptel--save-buffer-to-file every cj/gptel-autosave-interval seconds (default 60) for as long as cj/gptel--autosave-active-p holds. Toggle-off and kill-buffer-hook cancel it cleanly. Tests cover start/stop idempotency, the active-p predicate, the kill-buffer cleanup hook, and the toggle integration.
-rw-r--r--modules/ai-conversations.el60
-rw-r--r--tests/test-ai-conversations.el105
2 files changed, 155 insertions, 10 deletions
diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el
index 03ea6ec9..fc234a82 100644
--- a/modules/ai-conversations.el
+++ b/modules/ai-conversations.el
@@ -51,11 +51,53 @@ 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-local cj/gptel-autosave--timer nil
+ "Repeating timer used to auto-save the current GPTel buffer.")
+
+(defcustom cj/gptel-autosave-interval 60
+ "Seconds between periodic GPTel conversation autosaves."
+ :type 'number
+ :group 'cj/ai-conversations)
+
(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-active-p ()
+ "Return non-nil when the current buffer has an autosave target."
+ (and (bound-and-true-p gptel-mode)
+ cj/gptel-autosave-enabled
+ (stringp cj/gptel-autosave-filepath)
+ (> (length cj/gptel-autosave-filepath) 0)))
+
+(defun cj/gptel--autosave-stop-timer ()
+ "Cancel the current buffer's periodic autosave timer, if any."
+ (when cj/gptel-autosave--timer
+ (cancel-timer cj/gptel-autosave--timer)
+ (setq-local cj/gptel-autosave--timer nil)))
+
+(defun cj/gptel--autosave-timer-callback (buffer)
+ "Auto-save BUFFER from a periodic timer when autosave is still active."
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (if (cj/gptel--autosave-active-p)
+ (condition-case err
+ (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
+ (error (message "cj/gptel periodic autosave failed: %s"
+ (error-message-string err))))
+ (cj/gptel--autosave-stop-timer)))))
+
+(defun cj/gptel--autosave-start-timer ()
+ "Start the current buffer's periodic autosave timer when autosave is active."
+ (when (and (cj/gptel--autosave-active-p)
+ (not cj/gptel-autosave--timer))
+ (setq-local cj/gptel-autosave--timer
+ (run-with-timer cj/gptel-autosave-interval
+ cj/gptel-autosave-interval
+ #'cj/gptel--autosave-timer-callback
+ (current-buffer)))))
+
(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
@@ -68,11 +110,13 @@ so a path exists to autosave to."
(if cj/gptel-autosave-enabled
(progn
(setq-local cj/gptel-autosave-enabled nil)
+ (cj/gptel--autosave-stop-timer)
(message "Autosave disabled"))
(cond
((and (stringp cj/gptel-autosave-filepath)
(> (length cj/gptel-autosave-filepath) 0))
(setq-local cj/gptel-autosave-enabled t)
+ (cj/gptel--autosave-start-timer)
(message "Autosave enabled (saving to %s)"
(file-name-nondirectory cj/gptel-autosave-filepath)))
((y-or-n-p "No save target yet. Save conversation first? ")
@@ -110,8 +154,13 @@ construct."
(append mode-line-format
(list 'cj/gptel-autosave-mode-line-format)))))
+(defun cj/gptel--install-autosave-buffer-hooks ()
+ "Install buffer-local cleanup hooks for GPTel autosave."
+ (add-hook 'kill-buffer-hook #'cj/gptel--autosave-stop-timer nil t))
+
(with-eval-after-load 'gptel
- (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-mode-line))
+ (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-mode-line)
+ (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-buffer-hooks))
(defun cj/gptel--slugify-topic (s)
"Return a filesystem-friendly slug for topic string S."
@@ -226,7 +275,8 @@ Enable autosave for subsequent AI responses to the same file."
(cj/gptel--save-buffer-to-file buf filepath)
(with-current-buffer buf
(setq-local cj/gptel-autosave-filepath filepath)
- (setq-local cj/gptel-autosave-enabled t))
+ (setq-local cj/gptel-autosave-enabled t)
+ (cj/gptel--autosave-start-timer))
(message "Conversation saved to: %s" filepath))))
(defun cj/gptel-delete-conversation ()
@@ -261,7 +311,8 @@ Enable autosave for subsequent AI responses to the same file."
(defun cj/gptel-load-conversation ()
"Load a saved GPTel conversation into the AI-Assistant buffer.
-Prompt to save the current conversation first when appropriate, then enable autosave."
+Prompt to save the current conversation first when appropriate, then
+enable autosave."
(interactive)
(let ((ai-buffer (get-buffer-create "*AI-Assistant*")))
(when (and (with-current-buffer ai-buffer (> (buffer-size) 0))
@@ -289,7 +340,8 @@ Prompt to save the current conversation first when appropriate, then enable auto
(goto-char (point-max))
(set-buffer-modified-p t)
(setq-local cj/gptel-autosave-filepath filepath)
- (setq-local cj/gptel-autosave-enabled t))
+ (setq-local cj/gptel-autosave-enabled t)
+ (cj/gptel--autosave-start-timer))
(let ((buf (get-buffer "*AI-Assistant*")))
(unless (get-buffer-window buf)
(display-buffer-in-side-window
diff --git a/tests/test-ai-conversations.el b/tests/test-ai-conversations.el
index 26b2423b..2d5aefd1 100644
--- a/tests/test-ai-conversations.el
+++ b/tests/test-ai-conversations.el
@@ -328,21 +328,107 @@
;; Must not signal even though the write will fail
(cj/gptel--autosave-after-send)))))
+;; ------------------------------------------------------ autosave timer
+
+(ert-deftest test-ai-conversations-autosave-start-timer-normal ()
+ "Normal: starting autosave creates a repeating timer for the current buffer."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (let ((calls nil))
+ (cl-letf (((symbol-function 'run-with-timer)
+ (lambda (secs repeat function &rest args)
+ (push (list secs repeat function args) calls)
+ :fake-timer)))
+ (let ((cj/gptel-autosave-interval 17))
+ (cj/gptel--autosave-start-timer)))
+ (should (eq cj/gptel-autosave--timer :fake-timer))
+ (should (equal (caar calls) 17))
+ (should (equal (cadar calls) 17))
+ (should (eq (nth 2 (car calls)) #'cj/gptel--autosave-timer-callback))
+ (should (eq (car (nth 3 (car calls))) (current-buffer))))))
+
+(ert-deftest test-ai-conversations-autosave-start-timer-idempotent ()
+ "Boundary: starting autosave twice does not create a second timer."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (setq-local cj/gptel-autosave--timer :existing-timer)
+ (let ((created 0))
+ (cl-letf (((symbol-function 'run-with-timer)
+ (lambda (&rest _)
+ (setq created (1+ created))
+ :new-timer)))
+ (cj/gptel--autosave-start-timer))
+ (should (= created 0))
+ (should (eq cj/gptel-autosave--timer :existing-timer)))))
+
+(ert-deftest test-ai-conversations-autosave-stop-timer-cancels ()
+ "Normal: stopping autosave cancels the current buffer's timer."
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel--autosave-stop-timer))
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer))))
+
+(ert-deftest test-ai-conversations-autosave-timer-callback-saves-active-buffer ()
+ "Normal: timer callback saves the live buffer when autosave is active."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "timer.gptel" dir))
+ (buf (generate-new-buffer " *gptel timer test*")))
+ (unwind-protect
+ (with-current-buffer buf
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath file)
+ (insert "timer body")
+ (cj/gptel--autosave-timer-callback buf)
+ (should (file-exists-p file)))
+ (when (buffer-live-p buf)
+ (kill-buffer buf)))))))
+
+(ert-deftest test-ai-conversations-autosave-timer-callback-stops-inactive-buffer ()
+ "Boundary: timer callback cancels itself when autosave is no longer active."
+ (let ((buf (generate-new-buffer " *gptel timer inactive*")))
+ (unwind-protect
+ (with-current-buffer buf
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel--autosave-timer-callback buf))
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer)))
+ (when (buffer-live-p buf)
+ (kill-buffer buf)))))
+
;; ------------------------------------------------------ save-conversation
(ert-deftest test-ai-conversations-save-conversation-interactive-new-topic ()
- "Save-conversation writes file under the topic-slugged name."
+ "Save-conversation writes file, enables autosave, and starts a timer."
(test-ai-conversations--with-temp-dir
(lambda (dir)
(let ((ai-buffer (generate-new-buffer "*AI-Assistant*")))
(unwind-protect
(progn
(with-current-buffer ai-buffer
+ (setq-local gptel-mode t)
(insert "session content"))
(cl-letf (((symbol-function 'completing-read)
(lambda (&rest _) "Test Topic"))
((symbol-function 'y-or-n-p)
- (lambda (&rest _) nil)))
+ (lambda (&rest _) nil))
+ ((symbol-function 'run-with-timer)
+ (lambda (&rest _) :save-timer)))
(cj/gptel-save-conversation)
(let ((files (directory-files dir nil "test-topic_.*\\.gptel$")))
(should files)
@@ -350,7 +436,8 @@
;; Autosave state is set in the AI buffer
(with-current-buffer ai-buffer
(should cj/gptel-autosave-enabled)
- (should (stringp cj/gptel-autosave-filepath)))))
+ (should (stringp cj/gptel-autosave-filepath))
+ (should (eq cj/gptel-autosave--timer :save-timer)))))
(kill-buffer ai-buffer))))))
(ert-deftest test-ai-conversations-save-conversation-error-no-buffer ()
@@ -417,13 +504,19 @@
(should cj/gptel-autosave-enabled)))
(ert-deftest test-ai-conversations-autosave-toggle-disables ()
- "Toggle turns autosave off when already on."
+ "Toggle turns autosave off and cancels the periodic timer 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)))
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel-autosave-toggle))
+ (should-not cj/gptel-autosave-enabled)
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer))))
(ert-deftest test-ai-conversations-autosave-toggle-prompts-when-no-filepath ()
"Toggle prompts to save first when no filepath is configured."