add codex.el

This commit is contained in:
2026-01-09 13:34:29 -05:00
parent 2ffc66e4ad
commit b5cf61f904
3 changed files with 217 additions and 1 deletions

214
codex.el Normal file
View File

@@ -0,0 +1,214 @@
;;; codex.el --- Simple Codex chat buffer -*- lexical-binding: t; -*-
;; Copyright (C) 2025
;; Author: Grant Horner
;; Version: 0.1
;; Package-Requires: ((emacs "27.1"))
;; Keywords: tools, convenience
;;; Commentary:
;; Basic chat buffer for Codex using `codex exec --json`.
;; {"type":"thread.started","thread_id":"019ba390-af9c-7872-8617-c8038b61d559"}
;; {"type":"turn.started"}
;; {"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Responding with greeting**"}}
;; {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hi! How can I help?"}}
;; {"type":"turn.completed","usage":{"input_tokens":4050,"cached_input_tokens":3712,"output_tokens":13}}
(require 'json)
(require 'cl-lib)
(defgroup codex nil
"Chat with Codex."
:group 'tools)
(defcustom codex-command "codex"
"Codex executable to invoke."
:type 'string
:group 'codex)
(defcustom codex-skip-git-repo-check t
"Whether to pass --skip-git-repo-check to Codex."
:type 'boolean
:group 'codex)
(defcustom codex-buffer-name "*codex*"
"What the default name should be fore the codex chat buffer."
:type 'string
:group 'codex)
(defcustom codex-sessions-file (concat (file-name-parent-directory user-init-file) "codex-sessions")
"What the default name should be fore the codex chat buffer."
:type 'string
:group 'codex)
(defvar codex--session-id nil)
(defvar codex-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'codex-send)
(define-key map (kbd "C-c C-s") #'codex-switch-sessions)
(define-key map (kbd "C-c C-r") #'codex-reset)
(define-key map (kbd "M-p") #'codex-previous-prompt)
(define-key map (kbd "M-n") #'codex-next-prompt)
map)
"Keymap for `codex-mode'.")
(define-derived-mode codex-mode fundamental-mode "Codex"
"Major mode for chatting with Codex."
(setq-local buffer-read-only nil)
(setq-local truncate-lines t)
(setq-local codex--busy nil))
(defun list->hash-set (list &optional test)
"Return a hash table whose keys are the elements of LIST."
(let ((ht (make-hash-table :test (or test #'equal))))
(dolist (x list ht)
(puthash x t ht))))
(defmacro with-codex-buffer (&rest body)
`(with-current-buffer (get-buffer-create codex-buffer-name)
,@body))
(defun prompt-delimiter ()
(newline)
(insert "---")
(newline))
(defun user-prompt ()
(prompt-delimiter)
(insert "User:")
(newline))
(defun codex--write-to-chat (msg)
(with-codex-buffer
(newline)
(insert "Codex:")
(newline)
(insert msg)
(user-prompt)))
(defun codex--parse-session-id (jsons)
(let ((thread-started-message
(or (cl-find-if
(lambda (hm)
(and (string-equal (gethash "type" hm) "thread.started")
(gethash "thread_id" hm)))
jsons)
(make-hash-table))))
(gethash "thread_id" thread-started-message)))
(defun codex--parse-msg-from-response (response-string)
(let* ((output (string-trim response-string))
(lines (string-lines output))
(jsons (mapcar #'json-parse-string lines))
(msg (cl-find-if (lambda (hm) (let ((type (gethash "type" hm))
(item (gethash "item" hm)))
(and type
item
(string-equal type "item.completed")
(string-equal (gethash "type" item) "agent_message"))))
jsons))
(item (gethash "item" msg))
(text (gethash "text" item)))
;; We should make sure we get one and only one message here, otherwise bail out
(setf codex--session-id (codex--parse-session-id jsons))
text))
(defun codex--ensure-session-in-sessions-file (prompt)
(let ((sessions (codex--read-sessions-file)))
(unless (member codex--session-id (mapcar #'cdr sessions))
(codex--write-sessions-file (cons (cons prompt codex--session-id) sessions)))))
(defun codex--format-response (msg)
(with-temp-buffer
(insert msg)
(fill-paragraph)
(buffer-string)))
(defun codex--send (prompt)
"Sends a prompt to codex."
(let* ((buf (generate-new-buffer "*codex--send*"))
(proc (make-process
:name "codex"
:buffer buf
:command (if (and codex--session-id (not (string-empty-p codex--session-id)))
(progn
(message "Using session %s" codex--session-id)
(list "codex" "exec" "resume" codex--session-id "--json" prompt))
(progn
(message "Creating new session")
(list "codex" "exec" "--json" prompt))))))
(set-process-sentinel
proc
(lambda (p event)
(when (string= event "finished\n")
(with-current-buffer (process-buffer p)
(-> (buffer-string)
codex--parse-msg-from-response
codex--format-response
codex--write-to-chat)
(codex--ensure-session-in-sessions-file prompt)
(kill-buffer)))))))
(defun codex--get-current-user-prompt ()
(with-current-buffer codex-buffer-name
(let* ((haystack (buffer-string))
(needle "---\nUser:\n")
(index
(cl-search needle haystack :from-end t)))
(substring haystack (+ (length needle) index)))))
(defun codex-send ()
(interactive)
(with-current-buffer codex-buffer-name
(let ((prompt (codex--get-current-user-prompt)))
(if (string-equal prompt "")
(error "Prompt is empty!")
(newline)
(insert "---")
(codex--send prompt)))))
;;;###autoload
(defun codex (session)
"Open a Codex chat buffer."
(interactive
(list (completing-read "Session: " (cons '("New") (codex--read-sessions-file)))))
(let ((session-id (cdr (assoc session (codex--read-sessions-file)))))
(setf codex--session-id session-id)
(let ((buf (get-buffer-create codex-buffer-name)))
(pop-to-buffer buf)
(unless (derived-mode-p 'codex-mode)
(codex-mode))
(insert "---\nUser:\n")
(goto-char (point-max)))))
(defun codex--read-sessions-file ()
(with-temp-buffer
(insert-file-contents codex-sessions-file)
(goto-char (point-min))
(condition-case nil
(read (current-buffer))
(end-of-file nil))))
(defun codex--write-sessions-file (list-of-session-ids)
(let ((filename codex-sessions-file))
(with-temp-buffer
(insert ";;; -*- lisp-data -*-\n")
(let ((print-length nil)
(print-level nil))
(pp list-of-session-ids (current-buffer)))
(write-region nil nil filename nil 'silent))))
(defun codex-switch-sessions (new-session)
(interactive
(list (completing-read "Session: " (cons '("New") (codex--read-sessions-file)))))
(let ((session-id (cdr (assoc new-session (codex--read-sessions-file)))))
(setf codex--session-id session-id)
(with-codex-buffer
(prompt-delimiter)
(if session-id
(insert (concat "Switching to session " session-id))
(insert "Switching to new session"))
(user-prompt))))