From b5cf61f904db948fda75095acdb3686e5cad460c Mon Sep 17 00:00:00 2001 From: Grant Horner Date: Fri, 9 Jan 2026 13:34:29 -0500 Subject: [PATCH] add codex.el --- .gitignore | 3 +- codex.el | 214 +++++++++++++++++++++++++++++++++++++++++++++++++++++ init.el | 1 + 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 codex.el diff --git a/.gitignore b/.gitignore index e43cec1..2775076 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ history projects eshell parinfer-rust -.mc-lists.el \ No newline at end of file +.mc-lists.el +codex-sessions \ No newline at end of file diff --git a/codex.el b/codex.el new file mode 100644 index 0000000..b933d6e --- /dev/null +++ b/codex.el @@ -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)))) diff --git a/init.el b/init.el index 986d9f3..a5d3be5 100644 --- a/init.el +++ b/init.el @@ -385,3 +385,4 @@ (use-package dockerfile-mode) (load-file (concat (file-name-parent-directory user-init-file) "odin-mode.el")) +(load-file (concat (file-name-parent-directory user-init-file) "codex.el"))