688 lines
25 KiB
EmacsLisp
688 lines
25 KiB
EmacsLisp
;;; ellie.el --- Emacs client for ollie-9p -*- lexical-binding: t -*-
|
|
|
|
;; Version: 0.2.0
|
|
;; Package-Requires: ((emacs "27.1"))
|
|
;; Keywords: tools, ai, chat
|
|
|
|
;;; Commentary:
|
|
;;
|
|
;; ellie.el talks to an ollie-9p session through the ollie-9p CLI tool,
|
|
;; which speaks the 9P protocol directly over a Unix socket. No FUSE
|
|
;; mount required.
|
|
;;
|
|
;; Quick start:
|
|
;;
|
|
;; (require 'ellie) ; or use use-package / :load-path
|
|
;; M-x ellie ; opens *ellie*, attaches to last session or creates one
|
|
;;
|
|
;; In the *ellie* buffer:
|
|
;;
|
|
;; RET open an input window to compose and submit a prompt
|
|
;; C-c C-q open an input window to compose a queued prompt
|
|
;; C-c C-c stop the running turn
|
|
;; C-c C-k kill the current session
|
|
;; C-c C-n create a new session (also bound to "n" when idle)
|
|
;; C-c C-a attach to a different existing session
|
|
;; C-c C-p show the rendered system prompt
|
|
;; g force-refresh the chat log
|
|
;;
|
|
;; In the *ellie-input* window:
|
|
;;
|
|
;; C-c C-c submit
|
|
;; C-c C-k cancel
|
|
;;
|
|
;; State changes while the agent is running:
|
|
;;
|
|
;; Writes to cfg (backend=, model=, agent=) are rejected by the
|
|
;; server when the agent is not idle. Commands sent via ctl (e.g.
|
|
;; compact, clear) are dispatched asynchronously and cannot return
|
|
;; errors; if the agent is running, the command is silently rejected.
|
|
;; Read cfg and check state= to confirm a change took effect.
|
|
|
|
;;; Code:
|
|
|
|
(require 'ansi-color)
|
|
(require 'markdown-mode nil t)
|
|
|
|
;;;; ──────────────── Customization ────────────────
|
|
|
|
(defgroup ellie nil
|
|
"Emacs client for ollie-9p."
|
|
:group 'tools
|
|
:prefix "ellie-")
|
|
|
|
(defcustom ellie-default-session-opts nil
|
|
"Default options passed when creating a new session.
|
|
An alist of (KEY . VALUE) string pairs; valid keys are
|
|
\"backend\", \"model\", \"agent\", and \"cwd\"."
|
|
:type '(alist :key-type string :value-type string)
|
|
:group 'ellie)
|
|
|
|
(defcustom ellie-poll-interval 0.5
|
|
"Seconds between chat-file polls."
|
|
:type 'number
|
|
:group 'ellie)
|
|
|
|
(defcustom ellie-input-window-height 8
|
|
"Height in lines of the prompt input window."
|
|
:type 'integer
|
|
:group 'ellie)
|
|
|
|
;;;; ──────────────── Internal state ────────────────
|
|
|
|
(defvar ellie--session-id nil
|
|
"ID of the currently active session (global).")
|
|
|
|
(defvar ellie--agent-id nil
|
|
"Agent ID (OLLIE_UNAME) for the currently active session.")
|
|
|
|
(defvar ellie--chat-size -1
|
|
"Last known byte count of the chat file; -1 means unread.")
|
|
|
|
(defvar ellie--poll-timer nil
|
|
"Timer for periodic refresh.")
|
|
|
|
(defconst ellie--buf "*ellie*" "Name of the chat display buffer.")
|
|
(defconst ellie--ibuf "*ellie-input*" "Name of the prompt input buffer.")
|
|
|
|
;;;; ──────────────── 9P helpers ────────────────
|
|
|
|
(defun ellie--9p-read (path)
|
|
"Read PATH via ollie-9p and return contents as a string.
|
|
Returns nil on any error."
|
|
(condition-case nil
|
|
(with-temp-buffer
|
|
(if (= (call-process "ollie-9p" nil t nil "read" path) 0)
|
|
(buffer-string)
|
|
nil))
|
|
(error nil)))
|
|
|
|
(defun ellie--9p-write (path text)
|
|
"Write TEXT to PATH via ollie-9p."
|
|
(with-temp-buffer
|
|
(insert text)
|
|
(let ((rc (call-process-region (point-min) (point-max)
|
|
"ollie-9p" nil nil nil "write" path)))
|
|
(unless (zerop rc)
|
|
(user-error "ollie-9p write %s failed (exit %d)" path rc)))))
|
|
|
|
(defun ellie--9p-rdwr (path text)
|
|
"Write TEXT to PATH via ollie-9p rdwr and return the response.
|
|
Returns nil on any error."
|
|
(condition-case nil
|
|
(with-temp-buffer
|
|
(insert text)
|
|
(if (= (call-process-region (point-min) (point-max)
|
|
"ollie-9p" t t nil "rdwr" path) 0)
|
|
(buffer-string)
|
|
nil))
|
|
(error nil)))
|
|
|
|
(defun ellie--9p-ls (path)
|
|
"Return list of entry names under PATH via ollie-9p ls.
|
|
Returns nil on error."
|
|
(condition-case nil
|
|
(with-temp-buffer
|
|
(when (= (call-process "ollie-9p" nil t nil "ls" path) 0)
|
|
(let ((lines (split-string (buffer-string) "\n" t)))
|
|
(seq-remove (lambda (s) (string-prefix-p "." s)) lines))))
|
|
(error nil)))
|
|
|
|
(defun ellie--9p-exists-p (path)
|
|
"Return non-nil if PATH exists in the 9P namespace."
|
|
(= (call-process "ollie-9p" nil nil nil "stat" path) 0))
|
|
|
|
;;;; ──────────────── Paths ────────────────
|
|
|
|
(defun ellie--session-path ()
|
|
"Return the 9P path for the current session directory."
|
|
(unless ellie--session-id
|
|
(user-error "No active ollie session"))
|
|
(format "session/%s" ellie--session-id))
|
|
|
|
(defun ellie--agent-path ()
|
|
"Return the 9P path for the current agent directory.
|
|
Discovers the agent ID by listing the agent/ subdirectory."
|
|
(unless ellie--session-id
|
|
(user-error "No active ollie session"))
|
|
(if ellie--agent-id
|
|
(format "session/%s/agent/%s" ellie--session-id ellie--agent-id)
|
|
(let* ((parent (format "session/%s/agent" ellie--session-id))
|
|
(entries (ellie--9p-ls parent)))
|
|
(if entries
|
|
(let ((aid (car entries)))
|
|
(setq ellie--agent-id aid)
|
|
(format "%s/%s" parent aid))
|
|
(user-error "No agent found in session %s" ellie--session-id)))))
|
|
|
|
(defun ellie--session-file (name)
|
|
"Return the 9P path to file NAME for the current session.
|
|
Agent files (chat, prompt, cfg, ctl, etc.) resolve under the agent directory.
|
|
Signals `user-error' if no session is active."
|
|
(unless ellie--session-id
|
|
(user-error "No active ollie session"))
|
|
(format "%s/%s" (ellie--agent-path) name))
|
|
|
|
(defun ellie--last-session-path ()
|
|
(expand-file-name "~/.config/ollie/last-session"))
|
|
|
|
;;;; ──────────────── Last-session persistence ────────────────
|
|
|
|
(defun ellie--persist-session (id)
|
|
"Upsert the {cwd}TAB{id} entry for `default-directory' in the last-session file."
|
|
(let* ((path (ellie--last-session-path))
|
|
(cwd (expand-file-name default-directory))
|
|
(raw (and (file-exists-p path)
|
|
(with-temp-buffer
|
|
(insert-file-contents path)
|
|
(buffer-string))))
|
|
(lines (when raw
|
|
(seq-filter (lambda (l) (not (string-empty-p (string-trim l))))
|
|
(split-string raw "\n"))))
|
|
(entry (concat cwd "\t" id))
|
|
(found nil)
|
|
(new-lines
|
|
(mapcar (lambda (l)
|
|
(let ((parts (split-string l "\t" nil nil)))
|
|
(if (and (= (length parts) 2) (string= (car parts) cwd))
|
|
(progn (setq found t) entry)
|
|
l)))
|
|
lines)))
|
|
(make-directory (file-name-directory path) t)
|
|
(with-temp-buffer
|
|
(insert (concat (mapconcat #'identity
|
|
(if found new-lines (append new-lines (list entry)))
|
|
"\n")
|
|
"\n"))
|
|
(write-region (point-min) (point-max) path nil 'silent))))
|
|
|
|
(defun ellie--recall-session ()
|
|
"Return the last-used session ID for `default-directory' if it still exists, else nil."
|
|
(let* ((path (ellie--last-session-path))
|
|
(cwd (expand-file-name default-directory))
|
|
(raw (and (file-exists-p path)
|
|
(with-temp-buffer
|
|
(insert-file-contents path)
|
|
(buffer-string)))))
|
|
(when raw
|
|
(catch 'found
|
|
(dolist (line (split-string raw "\n"))
|
|
(let ((parts (split-string line "\t" nil nil)))
|
|
(when (and (= (length parts) 2)
|
|
(string= (car parts) cwd))
|
|
(let ((id (string-trim (cadr parts))))
|
|
(when (and (not (string-empty-p id))
|
|
(ellie--9p-exists-p (format "session/%s" id)))
|
|
(throw 'found id))))))))))
|
|
|
|
;;;; ──────────────── Session enumeration ────────────────
|
|
|
|
(defun ellie--session-ids ()
|
|
"Return a list of all current session IDs."
|
|
(ellie--9p-ls "session"))
|
|
|
|
;;;; ──────────────── Session lifecycle ────────────────
|
|
|
|
(defun ellie-new-session (&optional opts)
|
|
"Create a new ollie session and make it current.
|
|
OPTS is an alist of option pairs; defaults to `ellie-default-session-opts'.
|
|
Returns the new session ID."
|
|
(interactive)
|
|
(let* ((effective-opts (or opts ellie-default-session-opts))
|
|
;; cwd is required; inject it if not already present.
|
|
(effective-opts (if (assoc "cwd" effective-opts)
|
|
effective-opts
|
|
(cons (cons "cwd" default-directory) effective-opts)))
|
|
(before (ellie--session-ids))
|
|
(seen (make-hash-table :test 'equal))
|
|
(cmd (mapconcat (lambda (kv)
|
|
(format "%s=%s" (car kv) (cdr kv)))
|
|
effective-opts " ")))
|
|
(dolist (id before) (puthash id t seen))
|
|
(ellie--9p-write "session/new" (concat cmd "\n"))
|
|
(let ((deadline (+ (float-time) 5.0))
|
|
result)
|
|
(while (and (not result) (< (float-time) deadline))
|
|
(dolist (id (ellie--session-ids))
|
|
(unless (gethash id seen) (setq result id)))
|
|
(unless result (sleep-for 0.05)))
|
|
(unless result
|
|
(user-error "Timed out waiting for a new ollie session"))
|
|
(setq ellie--session-id result
|
|
ellie--agent-id nil) ; force rediscovery on next agent-path call
|
|
(ellie--persist-session result)
|
|
(message "Created ollie session %s" result)
|
|
result)))
|
|
|
|
(defun ellie-attach (id)
|
|
"Attach to an existing session by ID and make it current."
|
|
(interactive
|
|
(let ((ids (ellie--session-ids)))
|
|
(unless ids (user-error "No ollie sessions available"))
|
|
(list (completing-read "Attach to session: " ids nil t))))
|
|
(unless (ellie--9p-exists-p (format "session/%s" id))
|
|
(user-error "Session not found: %s" id))
|
|
(setq ellie--session-id id
|
|
ellie--agent-id nil) ; force rediscovery
|
|
(ellie--persist-session id)
|
|
(message "Attached to ollie session %s" id))
|
|
|
|
(defun ellie-kill-session ()
|
|
"Kill the current ollie session."
|
|
(interactive)
|
|
(unless ellie--session-id (user-error "No active session"))
|
|
(when (yes-or-no-p (format "Kill ollie session %s? " ellie--session-id))
|
|
(call-process "ollie-9p" nil nil nil "rm"
|
|
(format "session/%s" ellie--session-id))
|
|
(ellie--stop-watching)
|
|
(setq ellie--session-id nil
|
|
ellie--agent-id nil
|
|
ellie--chat-size -1)
|
|
(when-let (buf (get-buffer ellie--buf))
|
|
(with-current-buffer buf
|
|
(let ((inhibit-read-only t))
|
|
(erase-buffer)
|
|
(insert "(session killed)\n\n")
|
|
(insert "Press 'n' or C-c C-n to start a new session.\n"))))
|
|
(message "Session killed")))
|
|
|
|
(defun ellie-rename-session (new-name)
|
|
"Rename the current ollie session to NEW-NAME."
|
|
(interactive "sNew session name: ")
|
|
(unless ellie--session-id (user-error "No active session"))
|
|
(when (string-empty-p (string-trim new-name))
|
|
(user-error "Empty name"))
|
|
(ellie--9p-write (ellie--session-file "ctl") (concat "rn " new-name "\n"))
|
|
(setq ellie--session-id new-name
|
|
ellie--agent-id nil) ; agent path changes with session name
|
|
(ellie--persist-session new-name)
|
|
(force-mode-line-update)
|
|
(message "Renamed session to %s" new-name))
|
|
|
|
;;;; ──────────────── State ────────────────
|
|
|
|
(defun ellie--state ()
|
|
"Return the current session state string, or \"unknown\" on any error."
|
|
(condition-case nil
|
|
(let ((spec (ellie--9p-read (ellie--session-file "cfg"))))
|
|
(if (and spec (string-match "^state=\\(.*\\)$" spec))
|
|
(string-trim (match-string 1 spec))
|
|
"unknown"))
|
|
(error "unknown")))
|
|
|
|
(defun ellie--idle-p ()
|
|
"Return non-nil if the session is currently idle."
|
|
(ignore-errors (string= (ellie--state) "idle")))
|
|
|
|
;;;; ──────────────── Agent operations ────────────────
|
|
|
|
(defun ellie-submit (text)
|
|
"Submit TEXT as a prompt to the current session.
|
|
The server dispatches it asynchronously."
|
|
(when (string-empty-p (string-trim text))
|
|
(user-error "Empty prompt"))
|
|
(ellie--9p-write (ellie--session-file "prompt") text))
|
|
|
|
(defun ellie-queue (text)
|
|
"Enqueue TEXT as a prompt to run after the current turn finishes."
|
|
(when (string-empty-p (string-trim text))
|
|
(user-error "Empty prompt"))
|
|
(ellie--9p-write (ellie--session-file "fifo") text))
|
|
|
|
(defun ellie-stop ()
|
|
"Send a stop signal to the current turn."
|
|
(interactive)
|
|
(ellie--9p-write (ellie--session-file "ctl") "stop\n")
|
|
(message "ollie: stopped"))
|
|
|
|
;;;; ──────────────── Chat display ────────────────
|
|
|
|
(defvar-local ellie--mode-line-state "unknown"
|
|
"Cached session state string for the mode line.")
|
|
|
|
(defvar-local ellie--mode-line-usage ""
|
|
"Cached usage string for the mode line.")
|
|
|
|
(defun ellie--refresh ()
|
|
"Refresh the chat buffer if the chat file has grown.
|
|
Also updates the cached mode-line state."
|
|
(let* ((buf (get-buffer ellie--buf))
|
|
(chat (condition-case nil (ellie--session-file "chat") (error nil)))
|
|
(raw (and chat (ellie--9p-read chat)))
|
|
(size (and raw (length raw))))
|
|
(when buf
|
|
(with-current-buffer buf
|
|
(setq ellie--mode-line-state (ellie--state))
|
|
(setq ellie--mode-line-usage
|
|
(or (condition-case nil
|
|
(when-let (s (ellie--9p-read (ellie--session-file "stats")))
|
|
(when (string-match "ctxsz=\\(.+\\)" s)
|
|
(match-string 1 s)))
|
|
(error nil))
|
|
""))
|
|
;; The chat log is append-only, so size increasing means new content.
|
|
(when (and size (> size ellie--chat-size))
|
|
(setq ellie--chat-size size)
|
|
(let ((inhibit-read-only t)
|
|
(at-end (= (point) (point-max))))
|
|
(erase-buffer)
|
|
(insert raw)
|
|
(ansi-color-apply-on-region (point-min) (point-max))
|
|
(font-lock-ensure)
|
|
(when at-end (goto-char (point-max)))))
|
|
(force-mode-line-update)))))
|
|
|
|
;;;; ──────────────── Polling ────────────────
|
|
|
|
(defun ellie--start-watching ()
|
|
"Start a periodic timer to tail the chat file."
|
|
(ellie--stop-watching)
|
|
(setq ellie--poll-timer
|
|
(run-with-timer ellie-poll-interval ellie-poll-interval #'ellie--refresh)))
|
|
|
|
(defun ellie--stop-watching ()
|
|
"Cancel the refresh timer."
|
|
(when ellie--poll-timer
|
|
(cancel-timer ellie--poll-timer)
|
|
(setq ellie--poll-timer nil)))
|
|
|
|
;;;; ──────────────── Input buffer ────────────────
|
|
|
|
(defvar-local ellie--input-queued nil
|
|
"Non-nil when this input buffer will enqueue rather than submit.")
|
|
|
|
(defvar ellie-input-mode-map
|
|
(let ((m (make-sparse-keymap)))
|
|
(define-key m (kbd "C-c C-c") #'ellie-input-submit)
|
|
(define-key m (kbd "C-c C-k") #'ellie-input-cancel)
|
|
m)
|
|
"Keymap for `ellie-input-mode'.")
|
|
|
|
(define-derived-mode ellie-input-mode text-mode "Ollie-Input"
|
|
"Major mode for composing ollie prompts.
|
|
\\{ellie-input-mode-map}"
|
|
(setq-local header-line-format
|
|
'(:eval
|
|
(format " %s · C-c C-c submit · C-c C-k cancel"
|
|
(if ellie--input-queued
|
|
(propertize "Queue prompt" 'face 'warning)
|
|
(propertize "Send prompt" 'face 'success))))))
|
|
|
|
(defun ellie--open-input (&optional queued)
|
|
"Open the prompt input window below the current window.
|
|
QUEUED non-nil means the prompt will be enqueued, not submitted immediately."
|
|
(let ((buf (get-buffer-create ellie--ibuf)))
|
|
(with-current-buffer buf
|
|
(erase-buffer)
|
|
(ellie-input-mode)
|
|
(setq ellie--input-queued queued))
|
|
(display-buffer buf
|
|
`(display-buffer-below-selected
|
|
(window-height . ,ellie-input-window-height)))
|
|
(when-let (win (get-buffer-window buf t))
|
|
(select-window win))))
|
|
|
|
(defun ellie-input-submit ()
|
|
"Submit the contents of the input buffer as a prompt."
|
|
(interactive)
|
|
(let ((text (buffer-string))
|
|
(queued ellie--input-queued))
|
|
(ellie--close-input-window)
|
|
(if queued
|
|
(ellie-queue text)
|
|
(ellie-submit text))))
|
|
|
|
(defun ellie-input-cancel ()
|
|
"Discard the input and close the input window."
|
|
(interactive)
|
|
(ellie--close-input-window))
|
|
|
|
(defun ellie--close-input-window ()
|
|
"Kill the input buffer and delete its window."
|
|
(when-let (win (get-buffer-window ellie--ibuf t))
|
|
(delete-window win))
|
|
(when-let (buf (get-buffer ellie--ibuf))
|
|
(kill-buffer buf)))
|
|
|
|
;;;; ──────────────── Chat major mode ────────────────
|
|
|
|
(defvar ellie-mode-map
|
|
(let ((m (make-sparse-keymap)))
|
|
(define-key m (kbd "RET") #'ellie-send-input)
|
|
(define-key m (kbd "C-c C-q") #'ellie-queue-input)
|
|
(define-key m (kbd "C-c C-c") #'ellie-stop)
|
|
(define-key m (kbd "C-c C-k") #'ellie-kill-session)
|
|
(define-key m (kbd "C-c C-r") #'ellie-rename-session)
|
|
(define-key m (kbd "C-c C-n") #'ellie-new-and-open)
|
|
(define-key m (kbd "C-c C-a") #'ellie-attach-and-open)
|
|
(define-key m (kbd "C-c C-p") #'ellie-show-system-prompt)
|
|
(define-key m (kbd "n") #'ellie-new-and-open)
|
|
(define-key m (kbd "g") #'ellie-refresh)
|
|
m)
|
|
"Keymap for `ellie-mode'.")
|
|
|
|
(defun ellie-send-input ()
|
|
"Open the input window to compose and submit a prompt."
|
|
(interactive)
|
|
(ellie--open-input nil))
|
|
|
|
(defun ellie-queue-input ()
|
|
"Open the input window to compose a queued prompt."
|
|
(interactive)
|
|
(ellie--open-input t))
|
|
|
|
(defun ellie-refresh ()
|
|
"Force a refresh of the chat log."
|
|
(interactive)
|
|
(setq ellie--chat-size -1)
|
|
(ellie--refresh))
|
|
|
|
(define-derived-mode ellie-mode special-mode "Ollie"
|
|
"Major mode for the ollie chat interface.
|
|
|
|
Key bindings:
|
|
\\{ellie-mode-map}"
|
|
:group 'ellie
|
|
(buffer-disable-undo)
|
|
(when (fboundp 'markdown-mode)
|
|
(setq-local font-lock-defaults '(markdown-mode-font-lock-keywords t))
|
|
(font-lock-mode 1))
|
|
(setq-local mode-line-format
|
|
`("%e"
|
|
mode-line-front-space
|
|
(:eval
|
|
(propertize (format " ellie:%s" (or ellie--session-id "-"))
|
|
'face 'mode-line-buffer-id))
|
|
(:eval
|
|
(if (string= ellie--mode-line-state "idle")
|
|
(propertize "idle" 'face 'success)
|
|
(propertize ellie--mode-line-state 'face 'warning)))
|
|
(:eval
|
|
(when (not (string-empty-p ellie--mode-line-usage))
|
|
(concat " " (replace-regexp-in-string "%" "%%" ellie--mode-line-usage))))
|
|
" "
|
|
mode-line-end-spaces)))
|
|
|
|
;;;; ──────────────── System prompt ────────────────
|
|
|
|
(defun ellie-show-system-prompt ()
|
|
"Display the rendered system prompt for the current session in a buffer."
|
|
(interactive)
|
|
(let* ((path (ellie--session-file "ctl"))
|
|
(text (ellie--9p-rdwr path "systemprompt")))
|
|
(unless text
|
|
(user-error "Could not read system prompt"))
|
|
(let ((buf (get-buffer-create "*ellie-system-prompt*")))
|
|
(with-current-buffer buf
|
|
(let ((inhibit-read-only t))
|
|
(erase-buffer)
|
|
(insert text)
|
|
(goto-char (point-min)))
|
|
(view-mode 1)
|
|
(when (fboundp 'markdown-mode)
|
|
(markdown-mode)))
|
|
(pop-to-buffer buf))))
|
|
|
|
;;;; ──────────────── Entry points ────────────────
|
|
|
|
;;;###autoload
|
|
(defun ellie ()
|
|
"Open the ollie chat interface.
|
|
Attaches to the last session if it still exists; otherwise creates a new one."
|
|
(interactive)
|
|
(unless ellie--session-id
|
|
(if-let (last (ellie--recall-session))
|
|
(progn
|
|
(setq ellie--session-id last)
|
|
(message "Attached to last ollie session %s" last))
|
|
(ellie-new-session)))
|
|
(ellie--open-buf))
|
|
|
|
;;;###autoload
|
|
(defun ellie-new-and-open ()
|
|
"Create a new ollie session and open the chat buffer."
|
|
(interactive)
|
|
(ellie-new-session)
|
|
(ellie--open-buf))
|
|
|
|
;;;###autoload
|
|
(defun ellie-attach-and-open (id)
|
|
"Attach to session ID and open the chat buffer."
|
|
(interactive
|
|
(let ((ids (ellie--session-ids)))
|
|
(unless ids (user-error "No ollie sessions available"))
|
|
(list (completing-read "Session: " ids nil t))))
|
|
(ellie-attach id)
|
|
(ellie--open-buf))
|
|
|
|
(defun ellie--open-buf ()
|
|
"Open or switch to the *ollie* chat buffer."
|
|
(let ((buf (get-buffer-create ellie--buf)))
|
|
(with-current-buffer buf
|
|
(unless (derived-mode-p 'ellie-mode)
|
|
(ellie-mode))
|
|
(setq ellie--chat-size -1)
|
|
(ellie--refresh)
|
|
(ellie--start-watching))
|
|
(pop-to-buffer buf)))
|
|
|
|
;;;; ──────────────── Code completion ────────────────
|
|
|
|
(defcustom ellie-complete-backend
|
|
(or (getenv "OLLIE_COMPLETE_BACKEND") "")
|
|
"Backend for code completion (e.g. \"ollama\", \"openrouter\")."
|
|
:type 'string
|
|
:group 'ellie)
|
|
|
|
(defcustom ellie-complete-model
|
|
(or (getenv "OLLIE_COMPLETE_MODEL") "")
|
|
"Model for code completion (e.g. \"qwen3:4b\")."
|
|
:type 'string
|
|
:group 'ellie)
|
|
|
|
(defcustom ellie-complete-prefix-max 4000
|
|
"Maximum characters of context before point."
|
|
:type 'integer
|
|
:group 'ellie)
|
|
|
|
(defcustom ellie-complete-suffix-max 1000
|
|
"Maximum characters of context after point."
|
|
:type 'integer
|
|
:group 'ellie)
|
|
|
|
(defvar ellie--complete-timer nil
|
|
"Timer for polling completion result.")
|
|
|
|
(defun ellie--complete-strip (text)
|
|
"Strip markdown fences and info lines from TEXT."
|
|
(let ((lines (split-string text "\n")))
|
|
(setq lines (seq-remove (lambda (l)
|
|
(or (string-match-p "\\````" l)
|
|
(string-match-p "\\`:: " l)))
|
|
lines))
|
|
(string-join lines "\n")))
|
|
|
|
;; Ghost text state.
|
|
(defvar ellie--ghost-overlay nil "Overlay showing the ghost completion.")
|
|
(defvar ellie--ghost-text nil "The pending ghost text string.")
|
|
(defvar ellie--ghost-pos nil "Buffer position where ghost text should be inserted.")
|
|
(defvar ellie--ghost-region nil "Cons (BEG . END) of region to replace, or nil.")
|
|
|
|
(defface ellie-ghost '((t :foreground "gray50" :slant italic))
|
|
"Face for ghost-text completion suggestions."
|
|
:group 'ellie)
|
|
|
|
(defun ellie--complete-send (prefix suffix)
|
|
"Send a completion request with PREFIX and SUFFIX text.
|
|
Returns the raw response from the server."
|
|
(let ((payload (format "%s\n__COMPLETE_SEPARATOR__\n%s" prefix suffix)))
|
|
(with-temp-buffer
|
|
(insert payload)
|
|
(let ((rc (call-process-region (point-min) (point-max)
|
|
"ollie-9p" t t nil "rdwr" "complete")))
|
|
(if (zerop rc)
|
|
(buffer-string)
|
|
(error "ollie-9p complete failed (exit %d)" rc))))))
|
|
|
|
(defun ellie-complete-at-point (&optional prefix suffix)
|
|
"Request a completion for the region around point.
|
|
PREFIX and SUFFIX default to the text before and after point up to
|
|
`ellie-complete-prefix-max' and `ellie-complete-suffix-max' characters."
|
|
(interactive)
|
|
(let* ((prefix (or prefix
|
|
(buffer-substring-no-properties
|
|
(max (point-min) (- (point) ellie-complete-prefix-max))
|
|
(point))))
|
|
(suffix (or suffix
|
|
(buffer-substring-no-properties
|
|
(point)
|
|
(min (point-max) (+ (point) ellie-complete-suffix-max)))))
|
|
(raw (ellie--complete-send prefix suffix))
|
|
(text (ellie--complete-strip raw)))
|
|
(when (called-interactively-p 'interactive)
|
|
(message "Completion: %s" (truncate-string-to-width text 80 nil nil t)))
|
|
text))
|
|
|
|
(defun ellie--complete-insert (text)
|
|
"Insert TEXT as ghost text at point, or replace region if `ellie--ghost-region' is set."
|
|
(ellie--complete-cancel-ghost)
|
|
(let ((pos (point)))
|
|
(setq ellie--ghost-pos pos
|
|
ellie--ghost-text text
|
|
ellie--ghost-overlay (make-overlay pos pos))
|
|
(overlay-put ellie--ghost-overlay 'after-string
|
|
(propertize text 'face 'ellie-ghost))))
|
|
|
|
(defun ellie--complete-cancel-ghost ()
|
|
"Remove the ghost text overlay."
|
|
(when ellie--ghost-overlay
|
|
(delete-overlay ellie--ghost-overlay)
|
|
(setq ellie--ghost-overlay nil
|
|
ellie--ghost-text nil
|
|
ellie--ghost-pos nil
|
|
ellie--ghost-region nil)))
|
|
|
|
(defun ellie-complete-accept ()
|
|
"Accept the current ghost text, inserting it into the buffer."
|
|
(interactive)
|
|
(when ellie--ghost-text
|
|
(let ((text ellie--ghost-text)
|
|
(pos ellie--ghost-pos)
|
|
(reg ellie--ghost-region))
|
|
(ellie--complete-cancel-ghost)
|
|
(if reg
|
|
(progn
|
|
(delete-region (car reg) (cdr reg))
|
|
(goto-char (car reg))
|
|
(insert text))
|
|
(goto-char pos)
|
|
(insert text)))))
|
|
|
|
(defun ellie-complete-reject ()
|
|
"Reject and dismiss the current ghost text."
|
|
(interactive)
|
|
(ellie--complete-cancel-ghost))
|
|
|
|
(provide 'ellie)
|
|
|
|
;;; ellie.el ends here |