**coltonlewis.name: Emacs Agent Helpers [Org] All L1 (Kernel Hacker Mode) ---

Emacs Agent Helpers

I'm an Emacs boy at heart, so naturally I want to integrate coding agents into an Emacs centric workflow. After bouncing some ideas off the agents themselves, I decided on the bare minimum interface I thought I needed for a meaningful integration. There would be three functions.

  1. One to switch to the agent buffer and start the agent if needed.
  2. One to copy context from an Emacs buffer to the agent.
  3. One to copy and review the agent's suggested changes to files.

Initial Attempt

I tried a one-shot attempt of this with Claude, which looked superficially good but when I tried to use the functions I found they contained so many errors and subtle misunderstandings about elisp that I threw them out in frustration after a few iterations describing the problems with no obivious improvement.

Claude and Gemini both struggled here, but I found I actually preferred Geminis attempts at solutions. They were no more correct than Claude, but they were simpler and I felt more capable of understanding and modifying them myself. Claude seems to have a tendency to over-engineer. This wias a good thing for my Ada string formatting library earlier but a disaster here.

The more general problem seemed to be that these models are just terrible at elisp. My leading conjecture is that most of the training set is crap from personal dotfiles that never had to live in a production environment with many things abandoned and left to rot.

Doing It Myself

After a while of getting nowhere fast with the agents, I decided to undergo the slow path of writing the functions myself. The good thing about Emacs is the documentation is highly discoverable, so this did not take long. I did spend far too much time futzing with `ansi-term` because calling that function always creates a new terminal which was not the behavior I wanted. I had to read the elisp guts and call the lower level function `term-ansi-make-term` instead.

I also learned a good deal about interacting with subprocess buffers within Emacs. Sometimes I hit a few dead ends before discovering the right function to call. Unfortunately, I couldn't seem to get my code to automatically submit a prompt to the agent process. Both `(term-send-input)` and `(term-raw-send "\n")` only put a newline into the input box.

Agent Refinements

After writing the code myself, I realized the a solution to make the agents better at elisp. Give them an AGENTS.md file in my .emacs.d telling them what standard they should aim for, telling them their goal was to combine correctness with human readability and target the coding style of a well-written ELPA package. This seemed to work as the coding errors greatly reduced after.

With better agents, I asked them to critique my code. They had minor error handling and idiomatic suggestions and I applied their edits.

Helper Workflow

I bound each of these three functions to some keys in my init.el, taking care to make one for term's char-mode since that overrides a lot of the usual prefix keys. The basic workflow works like this.

  1. I start the agent with `ai-agent`
  2. I send the agent context about the buffer I am editing with `ai-agent-send-to`, which reads a prompt from the mini-buffer and includes the visited file and active region if they are available.
  3. The agent has instructions that instead of modifying a file directly, it should create a copy of FILE named FILE.proposal and output a special tag Proposed-Change-For: FILE
  4. I call `ai-agent-ediff-proposal` which searches the agent buffer for that tag and starts an ediff session between FILE and FILE.proposal.

Code

Below is the current iteration of the code.

  ;;; ai-agent.el --- A simple integration for AI agents -*- lexical-binding: t; -*-

  ;;; Commentary:

  ;; This file provides a simple framework for interacting with command-line
  ;; AI agents like Gemini or Claude from within Emacs. It provides a
  ;; customizable variable to set the preferred agent and functions to
  ;; launch it and send it prompts.

  ;;; Code:

  (require 'term)

  (defgroup ai-agent nil
    "Settings for AI agent integrations."
    :group 'applications)

  (defcustom ai-agent-command "gemini"
    "The command to run for the AI agent.
  This should be the name of an executable program on your system,
  for example \"gemini\" or \"claude\"."
    :type 'string
    :group 'ai-agent)

  ;; --- Functions ---

  (defun ai-agent ()
    "Start or switch to the AI agent's terminal buffer.
  If a terminal for `ai-agent-command' already exists, switch
  to it. Otherwise, create a new one. This command is idempotent."
    (interactive)
    (let* ((buffer-name (format "*%s*" ai-agent-command))
           (agent-buffer (get-buffer buffer-name)))
      (if (and agent-buffer (get-buffer-process agent-buffer))
          (switch-to-buffer agent-buffer)
        (switch-to-buffer (term-ansi-make-term buffer-name ai-agent-command)))
      (term-char-mode)))

  (defun ai-agent-send-to (prompt)
    "Send the current file, region, and a PROMPT to the AI agent.
  If a region is active, its content is included. The function
  errors if the agent buffer is not running."
    (interactive "sPrompt: ")
    (let* ((buffer-name (format "*%s*" ai-agent-command))
           (agent-buffer (get-buffer buffer-name))
           (agent-proc (and agent-buffer (get-buffer-process agent-buffer))))
      (unless agent-proc
        (error "Agent process for '%s' not found. Start with `ai-agent` first."
  	      ai-agent-command))

      (let* ((filename (buffer-file-name))
             (region
  	    (when (use-region-p)
  	      (buffer-substring-no-properties (region-beginning) (region-end))))
             (message (concat
                       (format "Prompt: %s" prompt)
                       (if filename (format "\nFile: %s" filename) "")
                       (if region (format "\n\nContext:\n```\n%s\n```" region) ""))))
        (term-send-string agent-proc message)
        (switch-to-buffer agent-buffer))))

  (provide 'ai-agent)

  (defun ai-agent-ediff-proposal ()
    "Find a 'Proposed-Change-For:' line and run ediff.
  Searches backwards for a line indicating a proposal file,
  constructs the '.proposal' filename, and then runs `ediff-files`
  to compare the original with the proposed changes."
    (interactive)
    (let (original-file proposal-file)
      ;; 1. Search backwards for the 'Proposed-Change-For:' marker.
      (save-excursion
        (goto-char (point-max))
        (when (re-search-backward "^Proposed-Change-For: \(.+\)$" nil t)
          (setq original-file (match-string 1))))

      (unless original-file
        (error "No 'Proposed-Change-For:' line found near the end of the buffer"))

      ;; 2. Construct the proposal filename and check that both files exist.
      (setq proposal-file (concat original-file ".proposal"))

      (unless (file-exists-p original-file)
        (error "Original file not found: %s" original-file))
      (unless (file-exists-p proposal-file)
        (error "Proposal file not found: %s" proposal-file))

      ;; 3. Launch ediff to compare the two files.
      (ediff-files original-file proposal-file)))