Skip to content

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:

(defvar chatgpt-history (make-ring 0)
  "Ring of request directories.")

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:

(ring-elements chatgpt-history) ;; nil

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:

(ring-elements chatgpt-history)
;; ("/home/tony/chatgpt-emacs/requests/w32X0a/")

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:

(defvar chatgpt-request-dir nil
  "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:

chatgpt-request-dir ;; "/home/tony/chatgpt-emacs/requests/utuN4O/"

Pressing M-p again updates the prompt buffer to show foo-1. The chatgpt-request-dir is again updated:

chatgpt-request-dir ;; "/home/tony/chatgpt-emacs/requests/w32X0a/"

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:

(defvar chatgpt-history (make-ring 0)
  "Ring of the request directories.")

When we attempt to access a previous prompt using M-p, we receive an error message:

Accessing an empty ring

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:

`chatgpt-history' empty.  Send a request first.

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)