16. Implementing Prompt History Feature
In this session, we'll integrate a prompt history feature, allowing us to navigate between previous prompts using the M-p and M-n keystrokes.
Binding M-p and M-n in chatgpt-mode-map¶
We define the commands chatgpt-previous and chatgpt-next, binding them to M-p and M-n respectively in chatgpt-mode-map. Initially, these commands will output "prev prompt" and "next prompt" in the echo area.
(defun chatgpt-previous ()
"Replace current buffer content with previous prompt."
(interactive)
(message "prev prompt"))
(defun chatgpt-next ()
"Replace current buffer content with next prompt."
(interactive)
(message "next prompt"))
(defvar chatgpt-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "M-p") 'chatgpt-previous)
(define-key map (kbd "M-n") 'chatgpt-next)
(define-key map (kbd "C-c C-c") 'chatgpt-send)
map)
"Keymap of `chatgpt-mode'.")
Defining chatgpt-history and chatgpt-push¶
We introduce the chatgpt-history variable to store the history of request directories, initializing it as an empty ring:
Whenever a request is sent, its directory will be added to this ring via the chatgpt-push function:
(defun chatgpt-push (req-dir)
"Push REQ-DIR into `chatgpt-history' ring."
(ring-insert+extend chatgpt-history req-dir t))
Now, we modify the chatgpt-send-request function to incorporate a call to chatgpt-push, ensuring each request directory is recorded before sending the request:
(defun chatgpt-send-request (prompt)
"Send the request with PROMPT to OpenAI."
(let* (...
(req (chatgpt-request prompt))
(req-dir (file-name-as-directory (make-temp-file nil t)))
...)
(chatgpt-push req-dir)
(make-process ...)))
We can confirm the functionality by checking that chatgpt-history starts empty:
Then we enter foo-1 into the prompt buffer and press C-c C-c to submit a request. After this, evaluating the following expression in the minibuffer confirms that the chatgpt-history now includes one request directory:
Next, we repeat the process with the prompt foo-2. Upon evaluation, checking the chatgpt-history again shows it contains two request directories:
(ring-elements chatgpt-history)
;; ("/home/tony/chatgpt-emacs/requests/utuN4O/"
;; "/home/tony/chatgpt-emacs/requests/w32X0a/")
Implementing the chatgpt-previous Command¶
Next, we redefine chatgpt-previous to update the prompt buffer with the last used prompt instead of just printing a message. We introduce chatgpt-request-dir, a variable to hold the request directory of the current prompt:
Each time a request is sent, we reset this variable to nil. We modify chatgpt-push accordingly:
(defun chatgpt-push (req-dir)
"Push REQ-DIR into `chatgpt-history' ring."
(setq chatgpt-request-dir nil)
(ring-insert+extend chatgpt-history req-dir t))
Let's revisit the chatgpt-previous function:
(defun chatgpt-previous ()
"Replace current buffer content with previous prompt."
(interactive)
(let* ((req-dir (if (null chatgpt-request-dir)
(ring-ref chatgpt-history 0)
(ring-next chatgpt-history chatgpt-request-dir)))
(req (with-temp-buffer
(insert-file-contents (concat req-dir "request.json"))
(chatgpt-json-read)))
(prompt (map-nested-elt req [:messages 0 :content])))
(setq chatgpt-request-dir req-dir)
(erase-buffer)
(save-excursion (insert prompt))))
We begin by binding req-dir to the request directory associated with the most recent request. If chatgpt-request-dir is nil, we use the ring-ref function to access the latest entry from the chatgpt-history ring. Otherwise, we obtain the next entry using ring-next.
Next, we bind req to the object that represents the request stored in req-dir. Using the map-nested-elt function, we extract the prompt from req.
Before updating the prompt buffer, we assign req-dir to the chatgpt-request-dir variable. This ensures that the subsequent invocation of chatgpt-previous will retrieve the next prompt in the chatgpt-history.
Finally, we clear the current buffer and insert the prompt value, which reflects the previous request corresponding to chatgpt-request-dir. The save-excursion function retains the cursor position at the beginning of the buffer.
Testing the chatgpt-previous Command¶
Before testing it, we verify that the chatgpt-request-dir variable is set to nil in the minibuffer.
In the prompt buffer, pressing M-p invokes the chatgpt-previous command, which updates the prompt buffer to display foo-2, the prompt of the previous request. In the minibuffer we check that the chatgpt-request-dir variable value has been updated:
Pressing M-p again updates the prompt buffer to show foo-1. The chatgpt-request-dir is again updated:
Using the ring-elements function, we can confirm that chatgpt-history contains two elements:
(ring-elements chatgpt-history)
;; ("/home/tony/chatgpt-emacs/requests/utuN4O/"
;; "/home/tony/chatgpt-emacs/requests/w32X0a/")
Now, pressing M-p once more in the prompt buffer updates it to show foo-2 again. This behavior occurs because foo-1 is the oldest element in the chatgpt-history ring, and ring-next wraps around to the most recent element after reaching the oldest.
Next, we enter the prompt foo-3 in the prompt buffer and send it to OpenAI by pressing C-c C-c. A subsequent check in the minibuffer confirms that chatgpt-request-dir is reset to nil. Thus, pressing M-p now updates the prompt buffer to display the last prompt, foo-3.
Handling Empty chatgpt-history¶
We successfully implemented the chatgpt-previous command, but we need to address the scenario where the chatgpt-history ring is empty.
Let's redefine the chatgpt-history variable as an empty ring with the following code:
When we attempt to access a previous prompt using M-p, we receive an error message:
To improve user experience, we modify the chatgpt-previous command to check if the chatgpt-history ring is empty. If it is, we display a more informative message in the echo area:
(defun chatgpt-previous ()
"Replace current buffer content with previous prompt."
(interactive)
(if (ring-empty-p chatgpt-history)
(message "`chatgpt-history' empty. Send a request first.")
...))
Now, when we press M-p in the prompt buffer with an empty chatgpt-history, we receive the message:
Following this prompt, we can send a request with the input foo-4 to OpenAI. After sending the request, when we press M-p again in the prompt buffer, it updates with foo-4, as expected.
Initializing chatgpt-history from Disk¶
The chatgpt-history ring currently contains request directories from the ongoing Emacs session. However, it does not include requests from previous sessions stored in the chatgpt-dir directory. To address this, we will initialize the chatgpt-history variable with those previously saved request directories.
To achieve this, we define the chatgpt-history-set function. This function ensures that the chatgpt-dir directory exists, and it sets the chatgpt-history variable to a ring containing the request directories retrieved from chatgpt-dir, ordered with the most recent requests listed first. We use the previously defined chatgpt-requests function alongside the ring-convert-sequence-to-ring function.
Here's the implementation of chatgpt-history-set:
(defun chatgpt-history-set ()
"Set `chatgpt-history' with request in `chatgpt-dir'."
(when (file-exists-p chatgpt-dir)
(setq chatgpt-history
(ring-convert-sequence-to-ring (chatgpt-requests)))))
To ensure that chatgpt-history is initialized only once during an Emacs session—specifically, the first time the chatgpt command is invoked and the *chatgpt* buffer is created—we will modify the chatgpt-mode definition. This modification will include a call to the chatgpt-history-set function to initialize the history at that point:
(define-derived-mode chatgpt-mode markdown-mode "ChatGPT"
"Major mode for ChatGPT interaction."
(setq mode-line-format
'(" "
mode-line-buffer-identification
" "
chatgpt-model
" "
mode-line-misc-info))
(make-directory chatgpt-dir t)
(chatgpt-history-set))
Now, after killing the *chatgpt* prompt buffer and calling the chatgpt command again, Emacs recreates the prompt buffer, invoking chatgpt-mode, which subsequently calls chatgpt-history-set. This action populates chatgpt-history with request directories from the last three lessons where we defined prompt history feature, as shown below:
chatgpt-history
;; (0 7 . ["/home/tony/chatgpt-emacs/requests/JVjcXV/"
;; "/home/tony/chatgpt-emacs/requests/wavggx/"
;; "/home/tony/chatgpt-emacs/requests/wVcThg/"
;; "/home/tony/chatgpt-emacs/requests/w32X0a/"
;; "/home/tony/chatgpt-emacs/requests/utuN4O/"
;; "/home/tony/chatgpt-emacs/requests/cOKJCK/"
;; "/home/tony/chatgpt-emacs/requests/vymuEI/"])
This structured approach ensures that all requests, regardless of session, are accessible in the chatgpt-history.
Refactoring for Clean Code¶
We rename the chatgpt-previous command to chatgpt-prompt, modify its signature to include a direction argument, and adjust its implementation to handle updating the prompt buffer with the previous prompt or the next prompt:
(defun chatgpt-prompt (direction)
"Replace current buffer content with DIRECTION prompt."
(interactive)
(if (ring-empty-p chatgpt-history)
(message "`chatgpt-history' empty. Send a request first.")
(let* ((req-dir
(if (null chatgpt-request-dir)
(ring-ref chatgpt-history 0)
(if (eq direction 'previous)
(ring-next chatgpt-history chatgpt-request-dir)
(ring-previous chatgpt-history chatgpt-request-dir))))
(req (with-temp-buffer
(insert-file-contents (concat req-dir "request.json"))
(chatgpt-json-read)))
(prompt (map-nested-elt req [:messages 0 :content])))
(setq chatgpt-request-dir req-dir)
(erase-buffer)
(save-excursion (insert prompt)))))
Finally we redefine the chatgpt-previous function and create the chatgpt-next function using the chatgpt-prompt function:
(defun chatgpt-previous ()
"Replace current buffer content with next prompt."
(interactive)
(chatgpt-prompt 'previous))
(defun chatgpt-next ()
"Replace current buffer content with next prompt."
(interactive)
(chatgpt-prompt 'next))
This concludes the implementation of the prompt history feature.
In our next session, we will implement a waiting widget that will be displayed in the mode line while awaiting a response from OpenAI.
chatgpt.el¶
The current implementation of the chatgpt.el package is as follows:
;;; chatgpt.el --- Simple ChatGPT integration -*- lexical-binding: t; -*-
(require 'json)
(require 'markdown-mode)
(defvar chatgpt-api-key
"sk-proj-7pQDxN...w-D40A"
"OpenAI API key.")
(defvar chatgpt-dir "/home/tony/chatgpt-emacs/requests/"
"Request directory.")
(defun chatgpt-json-read ()
(let ((json-key-type 'keyword)
(json-object-type 'plist)
(json-array-type 'vector))
(json-read)))
(defun chatgpt-json-encode (object)
(let ((json-encoding-pretty-print t))
(json-encode object)))
(defun chatgpt-command (req-path)
"Return the curl command."
(format
(concat "curl https://api.openai.com/v1/chat/completions "
"-H 'Content-Type: application/json' "
"-H 'Authorization: Bearer %s' "
"-d @%s")
chatgpt-api-key req-path))
(defvar chatgpt-model "gpt-4o"
"OpenAI model.")
(defun chatgpt-request (prompt)
"Return an OpenAI request with PROMPT."
`(:model ,chatgpt-model
:messages ,(vector `(:role "user" :content ,prompt))))
(defun chatgpt-callback (prompt response req-dir)
"Append PROMPT and RESPONSE to the prompt buffer with a link to REQ-DIR."
(let ((buff (get-buffer-create "*chatgpt[requests]*")))
(with-current-buffer buff
(markdown-mode)
(goto-char (point-max))
(insert "# Request\n\n"
"<!-- [](" req-dir ") -->\n\n"
"## Prompt\n\n" prompt "\n\n"
"## Response\n\n" response "\n\n"))
(with-selected-window (display-buffer buff nil)
(goto-char (point-max))
(re-search-backward "^## Response")
(recenter-top-bottom 0))
(message "Response received from OpenAI.")))
(defun chatgpt-send-request (prompt)
"Send the request with PROMPT to OpenAI."
(let* ((req (chatgpt-request prompt))
(temporary-file-directory chatgpt-dir)
(req-dir (file-name-as-directory (make-temp-file nil t)))
(req-path (concat req-dir "request.json"))
(timestamp-path (format "%stimestamp-%s" req-dir (time-to-seconds)))
(command (chatgpt-command req-path)))
(message "chatgpt: %s" req-dir)
(write-region (chatgpt-json-encode req) nil req-path)
(write-region "" nil timestamp-path)
(chatgpt-push req-dir)
(chatgpt-mode-line-waiting 'start)
(make-process
:name "chatgpt"
:buffer (generate-new-buffer-name "chatgpt")
:command (list "sh" "-c" command)
:sentinel
(lambda (process event)
(chatgpt-mode-line-waiting 'stop)
(if (not (string= event "finished\n"))
(let ((err `(:type "process-error" :error (:event ,event)))
(err-path (concat req-dir "error.json")))
(write-region (chatgpt-json-encode err) nil err-path)
(error "%S" err))
(let* ((resp (with-current-buffer (process-buffer process)
(goto-char (point-min))
(chatgpt-json-read))))
(if-let ((api-error (plist-get resp :error)))
(let ((err `(:type "api-error" :error ,api-error))
(err-path (concat req-dir "error.json")))
(write-region (chatgpt-json-encode err) nil err-path)
(error "%S" err))
(let ((response (map-nested-elt resp [:choices 0 :message :content]))
(resp-path (concat req-dir "response.json")))
(write-region (chatgpt-json-encode resp) nil resp-path)
(chatgpt-callback prompt response req-dir))))
(kill-buffer (process-buffer process)))))))
(defun chatgpt-send ()
"Send the current prompt to OpenAI."
(interactive)
(chatgpt-send-request (buffer-string))
(erase-buffer)
(when (> (length (window-list)) 1)
(delete-window))
(message "Request sent to OpenAI."))
(defun chatgpt-timestamp (file)
"Return the timestamp number associated with timestamp FILE."
(string-to-number (nth 1 (string-split file "timestamp-"))))
(defun chatgpt-requests ()
"Return a sorted list of the requests in `chatgpt-dir'.
The most recent requests are listed first."
(let ((files (directory-files-recursively chatgpt-dir "timestamp.*")))
(mapcar (lambda (f) (string-trim-right f "timestamp.*"))
(seq-sort
(lambda (f1 f2) (> (chatgpt-timestamp f1) (chatgpt-timestamp f2)))
files))))
(defvar chatgpt-request-dir nil
"Hold request directory of the current prompt.")
(defvar chatgpt-history (make-ring 0)
"Ring of the request directories.")
(defun chatgpt-history-set ()
"Set `chatgpt-history' with request in `chatgpt-dir'."
(when (file-exists-p chatgpt-dir)
(setq chatgpt-history (ring-convert-sequence-to-ring (chatgpt-requests)))))
(defun chatgpt-push (req-dir)
"Push REQ-DIR into `chatgpt-history' ring."
(setq chatgpt-request-dir nil)
(ring-insert+extend chatgpt-history req-dir t))
(defun chatgpt-prompt (direction)
"Replace current buffer content with DIRECTION prompt."
(interactive)
(if (ring-empty-p chatgpt-history)
(message "`chatgpt-history' empty. Send a request first.")
(let* ((req-dir (if (null chatgpt-request-dir)
(ring-ref chatgpt-history 0)
(if (eq direction 'previous)
(ring-next chatgpt-history chatgpt-request-dir)
(ring-previous chatgpt-history chatgpt-request-dir))))
(req (with-temp-buffer
(insert-file-contents (concat req-dir "request.json"))
(chatgpt-json-read)))
(prompt (map-nested-elt req [:messages 0 :content])))
(setq chatgpt-request-dir req-dir)
(erase-buffer)
(save-excursion (insert prompt)))))
(defun chatgpt-previous ()
"Replace current buffer content with next prompt."
(interactive)
(chatgpt-prompt 'previous))
(defun chatgpt-next ()
"Replace current buffer content with next prompt."
(interactive)
(chatgpt-prompt 'next))
(defvar chatgpt-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "M-p") 'chatgpt-previous)
(define-key map (kbd "M-n") 'chatgpt-next)
(define-key map (kbd "C-c C-c") 'chatgpt-send)
map)
"Keymap of `chatgpt-mode'.")
(define-derived-mode chatgpt-mode markdown-mode "ChatGPT"
"ChatGPT mode."
(setq mode-line-format
'(" "
mode-line-buffer-identification
" "
chatgpt-model
" "
mode-line-misc-info))
(make-directory chatgpt-dir t)
(chatgpt-history-set))
(defun chatgpt ()
"Display and Select the prompt buffer."
(interactive)
(let* ((buff-name "*chatgpt*")
(buff-p (get-buffer buff-name))
(buff (get-buffer-create buff-name)))
(select-window
(display-buffer-at-bottom
buff '(display-buffer-below-selected (window-height . 6))))
(when (not buff-p) (chatgpt-mode))))
(provide 'chatgpt)