aboutsummaryrefslogtreecommitdiff
path: root/modules/ai-conversations-browser.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-16 02:00:19 -0500
committerCraig Jennings <c@cjennings.net>2026-05-16 02:00:19 -0500
commit3f50f682053dd31d5fac96ecdf2b98aad1ce56d7 (patch)
treee87cdc207cba893ac68ad6dcbbff8da17fbe8232 /modules/ai-conversations-browser.el
parent6ee37e0a68d31909861cf59684d3601bf40f5abe (diff)
downloaddotemacs-3f50f682053dd31d5fac96ecdf2b98aad1ce56d7.tar.gz
dotemacs-3f50f682053dd31d5fac96ecdf2b98aad1ce56d7.zip
feat(ai-conversations-browser): dired-style browser for saved GPTel conversations
`cj/gptel-load-conversation` prompts via `completing-read`. A dedicated browser shows what each conversation is about at a glance and supports single-key load / delete / rename without having to scroll a minibuffer list. New module `modules/ai-conversations-browser.el` + `cj/gptel-browse-conversations` entry point bound to `C-; a b` ("browse conversations"). Opens `*GPTel-Conversations*` in `cj/gptel-browser-mode` (a `special-mode` derivative). Each row shows date, time, topic slug, and a preview of the most recent message (length configurable via `cj/gptel-browser-preview-length`, default 60 chars). Rows sort newest first. 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 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 cover the helpers (topic parsing, header stripping, preview shaping for truncate / short / empty cases, row-for-file with conversation + non-conversation filenames, rows enumeration, render output for empty + 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).
Diffstat (limited to 'modules/ai-conversations-browser.el')
-rw-r--r--modules/ai-conversations-browser.el241
1 files changed, 241 insertions, 0 deletions
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