diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-16 02:00:19 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-16 02:00:19 -0500 |
| commit | 3f50f682053dd31d5fac96ecdf2b98aad1ce56d7 (patch) | |
| tree | e87cdc207cba893ac68ad6dcbbff8da17fbe8232 | |
| parent | 6ee37e0a68d31909861cf59684d3601bf40f5abe (diff) | |
| download | dotemacs-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).
| -rw-r--r-- | modules/ai-config.el | 3 | ||||
| -rw-r--r-- | modules/ai-conversations-browser.el | 241 | ||||
| -rw-r--r-- | tests/test-ai-conversations-browser.el | 244 | ||||
| -rw-r--r-- | todo.org | 40 |
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 @@ -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 |
