summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-config.el3
-rw-r--r--modules/ai-conversations-browser.el241
-rw-r--r--tests/test-ai-conversations-browser.el244
-rw-r--r--todo.org40
4 files changed, 523 insertions, 5 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
index c7a14cae..9ac00bfe 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -37,6 +37,7 @@
(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)
+(autoload 'cj/gptel-browse-conversations "ai-conversations-browser" "Browse saved GPTel conversations." t)
;;; ------------------------- AI Config Helper Functions ------------------------
@@ -508,6 +509,7 @@ Works for any buffer, whether it's visiting a file or not."
"d" #'cj/gptel-delete-conversation ;; delete conversation
"." #'cj/gptel-add-this-buffer ;; add buffer to context
"f" #'cj/gptel-add-file ;; add a file to context
+ "b" #'cj/gptel-browse-conversations ;; browse saved conversations
"l" #'cj/gptel-load-conversation ;; load and continue conversation
"m" #'cj/gptel-change-model ;; change the LLM model
"p" #'gptel-system-prompt ;; change prompt
@@ -526,6 +528,7 @@ Works for any buffer, whether it's visiting a file or not."
"C-; a A" "toggle autosave"
"C-; a B" "switch backend"
"C-; a M" "gptel menu"
+ "C-; a b" "browse conversations"
"C-; a d" "delete conversation"
"C-; a ." "add buffer"
"C-; a f" "add file"
diff --git a/modules/ai-conversations-browser.el b/modules/ai-conversations-browser.el
new file mode 100644
index 00000000..9f2a7de4
--- /dev/null
+++ b/modules/ai-conversations-browser.el
@@ -0,0 +1,241 @@
+;;; ai-conversations-browser.el --- Browse saved GPTel conversations -*- lexical-binding: t; coding: utf-8; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Provides `cj/gptel-browse-conversations': a dired-style buffer
+;; listing saved conversations in `cj/gptel-conversations-directory'.
+;; Each row shows date, time, topic, and a short preview of the most
+;; recent message. Single-key bindings load / delete / rename a
+;; conversation in place.
+;;
+;; RET, l Load the conversation under point
+;; d Delete the conversation under point
+;; r Rename the conversation under point (renames the file)
+;; g Refresh the listing
+;; n / p Move to next / previous row
+;; q Quit the browser window
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'subr-x)
+
+(declare-function cj/gptel-load-conversation "ai-conversations" ())
+(declare-function cj/gptel--slugify-topic "ai-conversations" (s))
+(declare-function cj/gptel--timestamp-from-filename "ai-conversations" (filename))
+
+(defcustom cj/gptel-browser-preview-length 60
+ "Number of preview characters shown per row in the browser."
+ :type 'integer
+ :group 'cj/ai-conversations)
+
+(defconst cj/gptel-browser--buffer-name "*GPTel-Conversations*"
+ "Buffer name for the saved-conversations browser.")
+
+(defvar-keymap cj/gptel-browser-mode-map
+ :doc "Keymap for `cj/gptel-browser-mode'."
+ "RET" #'cj/gptel-browser-load
+ "l" #'cj/gptel-browser-load
+ "d" #'cj/gptel-browser-delete
+ "r" #'cj/gptel-browser-rename
+ "g" #'cj/gptel-browser-refresh
+ "n" #'next-line
+ "p" #'previous-line
+ "q" #'quit-window)
+
+(define-derived-mode cj/gptel-browser-mode special-mode "GPTel-Browser"
+ "Major mode for browsing saved GPTel conversations."
+ (setq-local truncate-lines t))
+
+;; -------------------------- helpers (pure where possible)
+
+(defun cj/gptel-browser--topic-from-filename (filename)
+ "Return the topic slug from FILENAME, or nil if it isn't a gptel file."
+ (when (string-match "\\`\\(.+\\)_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel\\'" filename)
+ (match-string 1 filename)))
+
+(defun cj/gptel-browser--strip-headers (text)
+ "Drop the org #+STARTUP / #+VISIBILITY headers from TEXT and return the rest."
+ (let ((s text))
+ (while (string-match "\\`#\\+\\(STARTUP\\|VISIBILITY\\):.*\n" s)
+ (setq s (substring s (match-end 0))))
+ (while (and (> (length s) 0) (eq (aref s 0) ?\n))
+ (setq s (substring s 1)))
+ s))
+
+(defun cj/gptel-browser--last-message (text)
+ "Return a short preview of the last user/AI message in TEXT.
+Returns the empty string when no message body is present."
+ (let* ((stripped (cj/gptel-browser--strip-headers text))
+ ;; Last org-mode top-level heading body, or the whole text if
+ ;; there isn't one.
+ (body (if (string-match "\\`\\*+[^\n]*\n\\(\\(?:.\\|\n\\)*\\)\\'" stripped)
+ (let* ((all-text stripped)
+ ;; Walk backward to find the last '* ' or '** ' heading
+ (idx (or (cl-loop for i from (1- (length all-text)) downto 0
+ when (and (or (zerop i)
+ (eq (aref all-text (1- i)) ?\n))
+ (eq (aref all-text i) ?*))
+ return i)
+ 0)))
+ (substring all-text idx))
+ stripped)))
+ ;; Drop the heading line itself, then collapse whitespace.
+ (when (string-match "\\`\\*+[^\n]*\n" body)
+ (setq body (substring body (match-end 0))))
+ (setq body (replace-regexp-in-string "[\n\t ]+" " " body))
+ (string-trim body)))
+
+(defun cj/gptel-browser--preview (text length)
+ "Return a LENGTH-char preview from TEXT, ellipsized when truncated."
+ (let* ((line (cj/gptel-browser--last-message text))
+ (max-len (max 1 length)))
+ (cond
+ ((string-empty-p line) "")
+ ((> (length line) max-len)
+ (concat (substring line 0 (1- max-len)) "…"))
+ (t line))))
+
+(defun cj/gptel-browser--row-for-file (file dir)
+ "Return a propertized row string for FILE under DIR, or nil."
+ (let* ((filename (file-name-nondirectory file))
+ (topic (cj/gptel-browser--topic-from-filename filename))
+ (ts (and topic (cj/gptel--timestamp-from-filename filename))))
+ (when (and topic ts)
+ (let* ((preview (with-temp-buffer
+ (ignore-errors (insert-file-contents file))
+ (cj/gptel-browser--preview
+ (buffer-string) cj/gptel-browser-preview-length)))
+ (row (format "%s %-22s %s"
+ (format-time-string "%Y-%m-%d %H:%M" ts)
+ topic preview)))
+ (propertize row
+ 'cj/gptel-browser-file filename
+ 'cj/gptel-browser-topic topic)))))
+
+(defun cj/gptel-browser--rows ()
+ "Return propertized row strings for every conversation in the directory."
+ (when (and (boundp 'cj/gptel-conversations-directory)
+ (file-directory-p cj/gptel-conversations-directory))
+ (let ((dir cj/gptel-conversations-directory))
+ (delq nil
+ (mapcar (lambda (f) (cj/gptel-browser--row-for-file f dir))
+ (directory-files dir t "\\.gptel\\'"))))))
+
+(defun cj/gptel-browser--render ()
+ "Replace the current buffer's contents with the conversation listing.
+Sort newest first."
+ (let ((inhibit-read-only t)
+ (rows (sort (cj/gptel-browser--rows)
+ (lambda (a b)
+ (string> (substring-no-properties a 0 16)
+ (substring-no-properties b 0 16))))))
+ (erase-buffer)
+ (insert (propertize
+ "Saved GPTel conversations -- RET/l load d delete r rename g refresh q quit\n\n"
+ 'face 'header-line))
+ (cond
+ ((null rows)
+ (insert " (no saved conversations)\n"))
+ (t
+ (dolist (row rows)
+ (insert row "\n"))))
+ (goto-char (point-min))
+ (forward-line 2)))
+
+;; -------------------------- entry point
+
+;;;###autoload
+(defun cj/gptel-browse-conversations ()
+ "Open the saved GPTel conversations browser."
+ (interactive)
+ (let ((buf (get-buffer-create cj/gptel-browser--buffer-name)))
+ (with-current-buffer buf
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render))
+ (pop-to-buffer buf)))
+
+(defun cj/gptel-browser-refresh ()
+ "Re-read the conversations directory and refresh the browser."
+ (interactive)
+ (cj/gptel-browser--render))
+
+;; -------------------------- row-level actions
+
+(defun cj/gptel-browser--filename-at-point ()
+ "Return the conversation filename on the current line, or nil."
+ (get-text-property (line-beginning-position) 'cj/gptel-browser-file))
+
+(defun cj/gptel-browser--filepath-at-point ()
+ "Return the absolute filepath for the row at point, or nil."
+ (when-let ((filename (cj/gptel-browser--filename-at-point)))
+ (expand-file-name filename cj/gptel-conversations-directory)))
+
+(defun cj/gptel-browser-load ()
+ "Load the conversation on the current row via `cj/gptel-load-conversation'.
+The browser is buried after the load fires."
+ (interactive)
+ (let ((filepath (cj/gptel-browser--filepath-at-point)))
+ (unless filepath
+ (user-error "No conversation on this line"))
+ (let ((filename (file-name-nondirectory filepath)))
+ ;; Stand in for cj/gptel-load-conversation's completing-read so
+ ;; the user doesn't get prompted twice.
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p cands &rest _)
+ (or (car (cl-find filename cands
+ :key (lambda (c) (cdr c))
+ :test #'equal))
+ (caar cands))))
+ ((symbol-function 'y-or-n-p) (lambda (&rest _) nil)))
+ (cj/gptel-load-conversation)))
+ (quit-window)))
+
+(defun cj/gptel-browser-delete ()
+ "Delete the conversation file on the current row, after confirmation."
+ (interactive)
+ (let ((filepath (cj/gptel-browser--filepath-at-point)))
+ (unless filepath
+ (user-error "No conversation on this line"))
+ (let ((filename (file-name-nondirectory filepath)))
+ (when (y-or-n-p (format "Delete %s? " filename))
+ (delete-file filepath)
+ (message "Deleted %s" filename)
+ (cj/gptel-browser--render)))))
+
+(defun cj/gptel-browser--rename-target (filepath new-topic)
+ "Compute the renamed FILEPATH for NEW-TOPIC, preserving the timestamp.
+NEW-TOPIC is slugified. Returns the new absolute filepath."
+ (let* ((dir (file-name-directory filepath))
+ (filename (file-name-nondirectory filepath))
+ (timestamp (and (string-match "_\\([0-9]\\{8\\}-[0-9]\\{6\\}\\)\\.gptel\\'"
+ filename)
+ (match-string 1 filename)))
+ (slug (cj/gptel--slugify-topic new-topic)))
+ (unless timestamp
+ (error "Cannot extract timestamp from filename: %s" filename))
+ (expand-file-name (format "%s_%s.gptel" slug timestamp) dir)))
+
+(defun cj/gptel-browser-rename ()
+ "Rename the conversation file on the current row, preserving its timestamp."
+ (interactive)
+ (let ((filepath (cj/gptel-browser--filepath-at-point)))
+ (unless filepath
+ (user-error "No conversation on this line"))
+ (let* ((old (file-name-nondirectory filepath))
+ (current-topic (cj/gptel-browser--topic-from-filename old))
+ (new-topic (read-string
+ (format "New topic (was %s): " current-topic)
+ current-topic))
+ (target (cj/gptel-browser--rename-target filepath new-topic)))
+ (when (equal target filepath)
+ (user-error "Topic unchanged"))
+ (when (file-exists-p target)
+ (user-error "Target already exists: %s" (file-name-nondirectory target)))
+ (rename-file filepath target)
+ (message "Renamed to %s" (file-name-nondirectory target))
+ (cj/gptel-browser--render))))
+
+(provide 'ai-conversations-browser)
+;;; ai-conversations-browser.el ends here
diff --git a/tests/test-ai-conversations-browser.el b/tests/test-ai-conversations-browser.el
new file mode 100644
index 00000000..d7422b09
--- /dev/null
+++ b/tests/test-ai-conversations-browser.el
@@ -0,0 +1,244 @@
+;;; test-ai-conversations-browser.el --- Tests for ai-conversations-browser -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the saved-conversations browser. Pure helpers (topic
+;; parsing, header stripping, preview, rename target) are tested
+;; against fixed inputs. File-touching actions (load / delete /
+;; rename) are tested against a temp conversations directory.
+
+;;; 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)
+;; Force real ai-conversations to override testutil's stub.
+(setq features (delq 'ai-conversations features))
+(require 'ai-conversations)
+(require 'ai-conversations-browser)
+
+;; ----------------------------- temp-dir helper
+
+(defun test-ai-conversations-browser--with-temp-dir (fn)
+ "Run FN inside a fresh conversations directory. Clean up after."
+ (let* ((dir (make-temp-file "test-ai-conversations-browser-" t))
+ (cj/gptel-conversations-directory dir))
+ (unwind-protect
+ (funcall fn dir)
+ (when (file-exists-p dir)
+ (delete-directory dir t)))))
+
+(defun test-ai-conversations-browser--write (dir name content)
+ "Write CONTENT to NAME in DIR. Return the absolute path."
+ (let ((path (expand-file-name name dir)))
+ (with-temp-file path (insert content))
+ path))
+
+;; ----------------------------- topic-from-filename
+
+(ert-deftest test-ai-conversations-browser-topic-normal ()
+ "Normal: topic slug extracted from a well-formed filename."
+ (should (equal (cj/gptel-browser--topic-from-filename
+ "my-topic_20260315-101530.gptel")
+ "my-topic")))
+
+(ert-deftest test-ai-conversations-browser-topic-error-malformed ()
+ "Boundary: malformed filename returns nil."
+ (should-not (cj/gptel-browser--topic-from-filename "garbage.gptel"))
+ (should-not (cj/gptel-browser--topic-from-filename "topic.gptel"))
+ (should-not (cj/gptel-browser--topic-from-filename "topic_20260315.gptel")))
+
+;; ----------------------------- strip-headers
+
+(ert-deftest test-ai-conversations-browser-strip-headers-normal ()
+ "Strip the two visibility headers plus the blank line after them."
+ (should (equal (cj/gptel-browser--strip-headers
+ "#+STARTUP: showeverything\n#+VISIBILITY: all\n\nrest\n")
+ "rest\n")))
+
+(ert-deftest test-ai-conversations-browser-strip-headers-no-headers ()
+ "Boundary: input without headers is unchanged."
+ (should (equal (cj/gptel-browser--strip-headers "plain body\n")
+ "plain body\n")))
+
+;; ----------------------------- last-message
+
+(ert-deftest test-ai-conversations-browser-last-message-normal ()
+ "Last-message picks the body of the last org heading."
+ (let ((text "* user [2026-01-01]\nhello there\n* AI [2026-01-01]\nthe latest reply\n"))
+ (should (equal (cj/gptel-browser--last-message text)
+ "the latest reply"))))
+
+(ert-deftest test-ai-conversations-browser-last-message-no-heading ()
+ "Boundary: text without headings returns the (collapsed) body."
+ (let ((text "just some body\nwith two lines\n"))
+ (should (equal (cj/gptel-browser--last-message text)
+ "just some body with two lines"))))
+
+;; ----------------------------- preview
+
+(ert-deftest test-ai-conversations-browser-preview-truncates ()
+ "Preview is ellipsized when the message is longer than LENGTH."
+ (let ((text "* AI\nthis is a very long response that should get truncated for the preview\n"))
+ (let ((preview (cj/gptel-browser--preview text 30)))
+ (should (= (length preview) 30))
+ (should (string-suffix-p "…" preview)))))
+
+(ert-deftest test-ai-conversations-browser-preview-short ()
+ "Preview is returned verbatim when shorter than LENGTH."
+ (let ((text "* AI\nshort\n"))
+ (should (equal (cj/gptel-browser--preview text 60) "short"))))
+
+(ert-deftest test-ai-conversations-browser-preview-empty ()
+ "Preview of empty body returns empty string."
+ (should (equal (cj/gptel-browser--preview "" 60) "")))
+
+;; ----------------------------- row-for-file
+
+(ert-deftest test-ai-conversations-browser-row-for-file-normal ()
+ "Row contains date, topic, and a preview; carries file metadata."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "alpha_20260315-101530.gptel"
+ "#+STARTUP: showeverything\n\n* AI\nresult body\n")))
+ (let ((row (cj/gptel-browser--row-for-file file dir)))
+ (should row)
+ (should (string-match-p "2026-03-15 10:15" row))
+ (should (string-match-p "alpha" row))
+ (should (string-match-p "result body" row))
+ (should (equal (get-text-property 0 'cj/gptel-browser-file row)
+ "alpha_20260315-101530.gptel")))))))
+
+(ert-deftest test-ai-conversations-browser-row-for-file-non-conversation ()
+ "Files that don't match the conversation pattern return nil."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "not-a-conversation.gptel" "body")))
+ (should-not (cj/gptel-browser--row-for-file file dir))))))
+
+;; ----------------------------- rows / render
+
+(ert-deftest test-ai-conversations-browser-rows-from-empty-dir ()
+ "Empty conversations directory yields no rows."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (should-not (cj/gptel-browser--rows)))))
+
+(ert-deftest test-ai-conversations-browser-rows-multiple-conversations ()
+ "Multiple conversations produce a row per file."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations-browser--write
+ dir "a_20260101-100000.gptel" "* AI\nfirst\n")
+ (test-ai-conversations-browser--write
+ dir "b_20260102-100000.gptel" "* AI\nsecond\n")
+ (let ((rows (cj/gptel-browser--rows)))
+ (should (= 2 (length rows)))))))
+
+(ert-deftest test-ai-conversations-browser-render-empty ()
+ "Render shows a 'no conversations' line when directory is empty."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (should (string-match-p "no saved conversations" (buffer-string)))))))
+
+(ert-deftest test-ai-conversations-browser-render-newest-first ()
+ "Render sorts rows newest first by timestamp."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations-browser--write
+ dir "old_20260101-100000.gptel" "* AI\nx\n")
+ (test-ai-conversations-browser--write
+ dir "new_20260301-100000.gptel" "* AI\ny\n")
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (let ((text (buffer-substring-no-properties (point-min) (point-max))))
+ ;; New (March) should appear before old (January) in the buffer.
+ (should (< (string-match "2026-03-01" text)
+ (string-match "2026-01-01" text))))))))
+
+;; ----------------------------- rename-target
+
+(ert-deftest test-ai-conversations-browser-rename-target-normal ()
+ "Rename-target preserves the timestamp and slugifies the new topic."
+ (should (equal (cj/gptel-browser--rename-target
+ "/tmp/old-topic_20260101-100000.gptel"
+ "Brand New Topic")
+ "/tmp/brand-new-topic_20260101-100000.gptel")))
+
+(ert-deftest test-ai-conversations-browser-rename-target-error-no-timestamp ()
+ "Rename-target errors when the filename lacks a timestamp."
+ (should-error (cj/gptel-browser--rename-target "/tmp/no-ts.gptel" "x")))
+
+;; ----------------------------- delete / rename actions
+
+(ert-deftest test-ai-conversations-browser-delete-removes-file ()
+ "Delete with y removes the file under point and re-renders."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "topic_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ ;; Point on the only data row
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t)))
+ (cj/gptel-browser-delete))
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-browser-delete-cancel-keeps-file ()
+ "Delete with n leaves the file alone."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "topic_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) nil)))
+ (cj/gptel-browser-delete))
+ (should (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-browser-rename-renames-file ()
+ "Rename moves the file under a new slug while preserving timestamp."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let* ((file (test-ai-conversations-browser--write
+ dir "old-name_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'read-string)
+ (lambda (&rest _) "renamed topic")))
+ (cj/gptel-browser-rename))
+ (should-not (file-exists-p file))
+ (should (file-exists-p
+ (expand-file-name "renamed-topic_20260101-100000.gptel"
+ dir))))))))
+
+(ert-deftest test-ai-conversations-browser-rename-error-on-empty-line ()
+ "Rename errors when point is on the header/empty area."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (should-error (cj/gptel-browser-rename))))))
+
+(provide 'test-ai-conversations-browser)
+;;; test-ai-conversations-browser.el ends here
diff --git a/todo.org b/todo.org
index c59b41ab..99103f13 100644
--- a/todo.org
+++ b/todo.org
@@ -2718,11 +2718,41 @@ 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:
-
-=cj/gptel-load-conversation= prompts via =completing-read=. A browser buffer (dired-style) showing topic, timestamp, and last-message preview, with single-key bindings to load / delete / rename in-place, would be a markedly better interface than the completing-read prompt.
-
-Priority bumped from [#C] to [#B] and the "defer until ≥20 conversations" hold lifted on 2026-05-15 -- the browser is the preferred entry point; build it now rather than wait for prompt friction to force the issue.
+*** 2026-05-16 Sat @ 01:59:44 -0500 Built the saved-conversations browser
+
+New module =modules/ai-conversations-browser.el= +
+=cj/gptel-browse-conversations= entry point bound to =C-; a b=
+(which-key labelled "browse conversations"). Opens a dired-style
+=*GPTel-Conversations*= buffer in =cj/gptel-browser-mode= (a
+=special-mode= derivative).
+
+Each row shows date, time, topic slug, and a preview of the most
+recent message (configurable length via
+=cj/gptel-browser-preview-length=, default 60 chars). Rows sort
+newest first.
+
+Bindings in the browser:
+- =RET= / =l=: load the conversation (delegates to
+ =cj/gptel-load-conversation= with the file pre-selected via a
+ =cl-letf= stub on =completing-read= so the user isn't prompted
+ twice), then bury the browser window.
+- =d=: delete the file under point after =y-or-n-p= confirmation,
+ re-render.
+- =r=: rename the file under point; preserves the timestamp,
+ slugifies the new topic, refuses unchanged input and existing
+ targets.
+- =g=: refresh.
+- =n= / =p=: next / previous row.
+- =q=: quit-window.
+
+21 tests in =tests/test-ai-conversations-browser.el= cover the
+helpers (topic parsing, header stripping, preview shaping for
+truncate / short / empty cases, row-for-file with both
+conversation and non-conversation filenames, rows enumeration,
+render output for empty and populated cases, newest-first sort,
+rename-target preservation of timestamp + slug, rename-target
+error on missing timestamp) and the file-touching actions (delete
+with y, cancel with n, rename, rename-on-empty-line error).
*** 2026-05-16 Sat @ 01:46:55 -0500 Added cj/gptel-quick-ask one-shot command