1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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
|