ellie.el: session tree, multi-agent paths, agent creation

- Replace flat session list with collapsible tree (Active/Inactive headers,
  sessions, agents — arbitrary depth)
- Fix path resolution: use session/{sname}/agent/{aname}/ layout
- Read state from state file instead of parsing cfg
- Add ellie-new-agent: write to session/{sname}/agent/new
- Add ellie-kill-agent for per-agent kill
- Persist agent ID in last-session for recall
- Mode-line shows session/agent
- q in chat buffer returns to tree
This commit is contained in:
Ollie Agent 2026-08-04 19:59:01 +02:00
parent 5ab073ebdb
commit 32461e84f0
1 changed files with 394 additions and 288 deletions

View File

@ -1,43 +1,33 @@
;;; ellie.el --- Emacs client for ollie-9p -*- lexical-binding: t -*-
;; Version: 0.2.0
;; Version: 0.3.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.
;; ellie.el talks to an ollie agent session through the ollie-9p CLI tool.
;; Sessions and agents are exposed as a collapsible tree (like dired for the
;; 9P namespace). Create sessions by writing to session/new, create agents
;; by writing to session/{sname}/agent/new — both are regular files.
;;
;; Quick start:
;;
;; (require 'ellie) ; or use use-package / :load-path
;; M-x ellie ; opens *ellie*, attaches to last session or creates one
;; (require 'ellie)
;; M-x ellie ; opens session tree, attaches to last session
;;
;; In the *ellie* buffer:
;; In the *ellie* buffer (tree view):
;;
;; 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.
;; RET / E expand/collapse tree, or select session/agent
;; C-c C-n new session
;; C-c C-a new agent in current session
;; C-c C-c send prompt to selected agent
;; C-c C-q queue prompt for selected agent
;; C-c C-s stop running agent
;; C-c C-k kill session or agent at point
;; C-c C-p show system prompt
;; g refresh tree
;; q quit
;;; Code:
@ -71,19 +61,19 @@ An alist of (KEY . VALUE) string pairs; valid keys are
;;;; ──────────────── Internal state ────────────────
(defvar ellie--session-id nil
"ID of the currently active session (global).")
"Name of the currently active session.")
(defvar ellie--agent-id nil
"Agent ID (OLLIE_UNAME) for the currently active session.")
"Name of the currently active agent.")
(defvar ellie--chat-size -1
"Last known byte count of the chat file; -1 means unread.")
"Last known byte count of the chat file.")
(defvar ellie--poll-timer nil
"Timer for periodic refresh.")
(defvar ellie--tree-cache nil
"Parsed tree data from session/idx: ((paused name id agents...) ...)")
(defconst ellie--buf "*ellie*" "Name of the chat display buffer.")
(defconst ellie--ibuf "*ellie-input*" "Name of the prompt input buffer.")
(defconst ellie--tbuf "*ellie-sessions*" "Name of the session tree buffer.")
;;;; ──────────────── 9P helpers ────────────────
@ -120,44 +110,83 @@ Returns nil on error."
"Return non-nil if PATH exists in the 9P namespace."
(= (call-process "ollie-9p" nil nil nil "stat" path) 0))
;;;; ──────────────── Paths ────────────────
;;;; ──────────────── Session/agent 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--session-path (&optional sid)
"Return the 9P path for the session directory.
Defaults to `ellie--session-id' if SID is nil."
(format "session/%s" (or sid ellie--session-id
(user-error "No active ollie session"))))
(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--agent-path (&optional sid aid)
"Return the 9P path for the agent directory.
Defaults to current session/agent. If AID is nil, discovers
the first agent in the session."
(let* ((s (or sid ellie--session-id (user-error "No session")))
(a (or aid ellie--agent-id)))
(if a
(format "session/%s/agent/%s" s a)
;; Auto-discover first agent
(let ((agents (ellie--agents-for s)))
(if agents
(let ((aid2 (car (last (car agents))))) ; agent ID is last field
(when (and (not ellie--agent-id) (string= s ellie--session-id))
(setq ellie--agent-id aid2))
(format "session/%s/agent/%s" s aid2))
(user-error "No agents in session %s" s))))))
(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--session-file (name &optional sid aid)
"Return the 9P path to file NAME for the given session/agent."
(format "%s/%s" (ellie--agent-path sid aid) name))
(defun ellie--last-session-path ()
(expand-file-name "~/.config/ollie/last-session"))
;;;; ──────────────── Session index parsing ────────────────
(defun ellie--parse-session-idx ()
"Parse session/idx into a list: ((paused name id agents...) ...)
where agents is (name id state ...)."
(let ((raw (ellie--9p-read "session/idx"))
result)
(when raw
(dolist (line (split-string raw "\n" t))
(let* ((parts (split-string line "\t"))
(name (nth 0 parts))
(id (nth 1 parts))
(paused (string= (nth 2 parts) "true"))
(agents-str (nth 7 parts))
(agents nil))
(when agents-str
(dolist (a (split-string agents-str ";"))
(let ((aparts (split-string a ":")))
(when (>= (length aparts) 3)
(push (list (nth 0 aparts) (nth 1 aparts) (nth 2 aparts)) agents)))))
(push (list paused name id (nreverse agents)) result))))
(setq ellie--tree-cache (nreverse result))))
(defun ellie--sessions-by-paused ()
"Return (ACTIVE INACTIVE) lists of (paused name id agents...)."
(let* ((parsed (ellie--parse-session-idx))
(active (seq-filter (lambda (e) (not (car e))) parsed))
(inactive (seq-filter #'car parsed)))
(list active inactive)))
(defun ellie--agents-for (sid)
"Return agent list (name id state) for session SID from cache."
(let ((entry (assoc sid ellie--tree-cache
(lambda (a b) (string= (nth 2 a) (or b ""))))))
(if entry (nth 3 entry)
;; Fallback: refresh and try again
(ellie--parse-session-idx)
(let ((entry2 (assoc sid ellie--tree-cache
(lambda (a b) (string= (nth 2 a) (or b ""))))))
(if entry2 (nth 3 entry2) nil)))))
;;;; ──────────────── Last-session persistence ────────────────
(defun ellie--persist-session (id)
"Upsert the {cwd}TAB{id} entry for `default-directory' in the last-session file."
(defun ellie--persist-session (sid)
"Persist the current session/agent for `default-directory'."
(let* ((path (ellie--last-session-path))
(cwd (expand-file-name default-directory))
(raw (and (file-exists-p path)
@ -167,12 +196,12 @@ Signals `user-error' if no session is active."
(lines (when raw
(seq-filter (lambda (l) (not (string-empty-p (string-trim l))))
(split-string raw "\n"))))
(entry (concat cwd "\t" id))
(entry (concat cwd "\t" sid "\t" (or ellie--agent-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))
(if (and (>= (length parts) 2) (string= (car parts) cwd))
(progn (setq found t) entry)
l)))
lines)))
@ -185,7 +214,7 @@ Signals `user-error' if no session is active."
(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."
"Return (SESSION-ID AGENT-ID) for `default-directory' if it still exists."
(let* ((path (ellie--last-session-path))
(cwd (expand-file-name default-directory))
(raw (and (file-exists-p path)
@ -196,83 +225,98 @@ Signals `user-error' if no session is active."
(catch 'found
(dolist (line (split-string raw "\n"))
(let ((parts (split-string line "\t" nil nil)))
(when (and (= (length parts) 2)
(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"))
(let ((sid (string-trim (nth 1 parts)))
(aid (and (>= (length parts) 3) (string-trim (nth 2 parts)))))
(when (and (not (string-empty-p sid))
(ellie--9p-exists-p (format "session/%s" sid)))
(throw 'found (list sid aid)))))))))))
;;;; ──────────────── 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."
Returns the new session name."
(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 " ")))
(before (ellie--9p-ls "session"))
(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))
(dolist (id (ellie--9p-ls "session"))
(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--agent-id nil)
(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."
(defun ellie-new-agent (&optional sid)
"Create a new agent in session SID (defaults to current).
Prompts for agent profile and other options."
(interactive)
(let* ((s (or sid ellie--session-id (user-error "No session")))
(profiles (ellie--9p-ls "agents"))
(agent (if profiles (completing-read "Agent profile: " profiles nil t) "default"))
(cwd (read-directory-name "Working directory: " default-directory))
(cmd (format "agent=%s cwd=%s" agent cwd)))
(ellie--9p-write (format "session/%s/agent/new" s) (concat cmd "\n"))
(ellie--parse-session-idx)
(message "Created agent in session %s" s)))
(defun ellie-attach (sid &optional aid)
"Attach to session SID and optionally agent AID."
(interactive
(let ((ids (ellie--session-ids)))
(let ((ids (ellie--9p-ls "session")))
(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))
(unless (ellie--9p-exists-p (format "session/%s" sid))
(user-error "Session not found: %s" sid))
(setq ellie--session-id sid
ellie--agent-id aid)
(ellie--persist-session sid)
(message "Attached to ollie session %s (agent %s)" sid (or aid "auto")))
(defun ellie-kill-session ()
"Kill the current ollie session."
(defun ellie-kill-session (&optional sid)
"Kill the session at point or current 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")))
(let* ((s (or sid ellie--session-id (user-error "No session"))))
(when (yes-or-no-p (format "Kill ollie session %s? " s))
(call-process "ollie-9p" nil nil nil "write"
(format "session/%s/ctl" s) "kill\n")
(when (string= s ellie--session-id)
(setq ellie--session-id nil
ellie--agent-id nil
ellie--chat-size -1))
(ellie--parse-session-idx)
(message "Session %s killed" s))))
(defun ellie-kill-agent (&optional sid aid)
"Kill agent AID in session SID."
(interactive)
(let* ((s (or sid ellie--session-id (user-error "No session")))
(a (or aid ellie--agent-id (user-error "No agent"))))
(when (yes-or-no-p (format "Kill agent %s in session %s? " a s))
(call-process "ollie-9p" nil nil nil "write"
(format "session/%s/agent/%s/ctl" s a) "kill\n")
(when (and (string= s ellie--session-id) (string= a ellie--agent-id))
(setq ellie--agent-id nil))
(ellie--parse-session-idx)
(message "Agent %s killed" a))))
(defun ellie-rename-session (new-name)
"Rename the current ollie session to NEW-NAME."
@ -280,9 +324,10 @@ Returns the new session ID."
(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"))
(ellie--9p-write (format "session/%s/name" ellie--session-id)
(concat new-name "\n"))
(setq ellie--session-id new-name
ellie--agent-id nil) ; agent path changes with session name
ellie--agent-id nil)
(ellie--persist-session new-name)
(force-mode-line-update)
(message "Renamed session to %s" new-name))
@ -290,32 +335,29 @@ Returns the new session ID."
;;;; ──────────────── State ────────────────
(defun ellie--state ()
"Return the current session state string, or \"unknown\" on any error."
"Return the current agent state string."
(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"))
(let ((s (ellie--9p-read (ellie--session-file "state"))))
(if s (string-trim s) "unknown"))
(error "unknown")))
(defun ellie--idle-p ()
"Return non-nil if the session is currently idle."
"Return non-nil if the current agent is 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."
(defun ellie-submit (text &optional sid aid)
"Submit TEXT as a prompt to session SID agent AID."
(when (string-empty-p (string-trim text))
(user-error "Empty prompt"))
(ellie--9p-write (ellie--session-file "prompt") text))
(ellie--9p-write (ellie--session-file "prompt" sid aid) text))
(defun ellie-queue (text)
"Enqueue TEXT as a prompt to run after the current turn finishes."
(defun ellie-queue (text &optional sid aid)
"Enqueue TEXT as a prompt to run after the current turn."
(when (string-empty-p (string-trim text))
(user-error "Empty prompt"))
(ellie--9p-write (ellie--session-file "fifo.in") text))
(ellie--9p-write (ellie--session-file "fifo.in" sid aid) text))
(defun ellie-stop ()
"Send a stop signal to the current turn."
@ -323,19 +365,210 @@ The server dispatches it asynchronously."
(ellie--9p-write (ellie--session-file "ctl") "stop\n")
(message "ollie: stopped"))
;;;; ──────────────── Session tree mode ────────────────
(defvar ellie-tree-mode-map
(let ((m (make-sparse-keymap)))
(define-key m (kbd "RET") #'ellie-tree-activate)
(define-key m (kbd "e") #'ellie-tree-toggle)
(define-key m (kbd "n") #'ellie-tree-new-session)
(define-key m (kbd "N") #'ellie-tree-new-agent)
(define-key m (kbd "c") #'ellie-tree-chat)
(define-key m (kbd "C-c C-s") #'ellie-stop)
(define-key m (kbd "C-c C-k") #'ellie-tree-kill)
(define-key m (kbd "C-c C-c") #'ellie-tree-send-input)
(define-key m (kbd "C-c C-q") #'ellie-tree-queue-input)
(define-key m (kbd "C-c C-p") #'ellie-tree-system-prompt)
(define-key m (kbd "g") #'ellie-tree-refresh)
(define-key m (kbd "q") #'kill-buffer-and-window)
m)
"Keymap for `ellie-tree-mode'.")
(defvar-local ellie--tree-expanded nil
"Set of expanded session IDs and header keys.")
(defvar-local ellie--tree-data nil
"Current tree data for rendering.")
(defun ellie--tree-build-data ()
"Build tree data: ((type key label extras...) ...)."
(let* ((sessions (ellie--parse-session-idx))
;; sessions: ((paused name id agents) ...)
(active (seq-filter (lambda (s) (not (car s))) sessions))
(inactive (seq-filter #'car sessions))
result)
;; Active header
(push (list 'header "active" "Active") result)
(when (and active (member "active" ellie--tree-expanded))
(dolist (s active)
(let ((name (nth 1 s)) (id (nth 2 s)) (agents (nth 3 s)))
(push (list 'session id name
(if agents (format "%s (%d)" name (length agents)) name))
result)
(when (and agents (member id ellie--tree-expanded))
(dolist (a agents)
(let ((aname (car a)) (aid (nth 1 a)) (state (nth 2 a)))
(push (list 'agent id aid
(format " %s [%s]" aname state))
result)))))))
;; Inactive header
(when inactive
(push (list 'header "inactive" "Inactive") result)
(when (member "inactive" ellie--tree-expanded)
(dolist (s inactive)
(let ((name (nth 1 s)) (id (nth 2 s)) (agents (nth 3 s)))
(push (list 'session id name
(if agents (format "%s (%d)" name (length agents)) name))
result)
(when (and agents (member id ellie--tree-expanded)
(dolist (a agents)
(let ((aname (car a)) (aid (nth 1 a)) (state (nth 2 a)))
(push (list 'agent id aid
(format " %s [%s]" aname state))
result)))))))))
(setq ellie--tree-data (nreverse result))))
(defun ellie--tree-render ()
"Render the session tree buffer."
(ellie--tree-build-data)
(let ((inhibit-read-only t)
(buf (get-buffer-create ellie--tbuf)))
(with-current-buffer buf
(erase-buffer)
(dolist (item ellie--tree-data)
(let ((type (nth 0 item))
(key (nth 1 item))
(label (nth 3 item)))
(insert
(cond
((eq type 'header)
(let* ((exp (member key ellie--tree-expanded))
(arrow (if exp "▼ " "▶ ")))
(concat (propertize (concat arrow label)
'face 'bold 'ellie-key key 'ellie-type 'header)
"\n")))
((eq type 'session)
(let* ((exp (member key ellie--tree-expanded))
(arrow (if exp "▼ " "▶ ")))
(concat (propertize (concat " " arrow label)
'ellie-key key 'ellie-type 'session)
"\n")))
((eq type 'agent)
(concat (propertize label
'ellie-key key
'ellie-parent (nth 1 item)
'ellie-type 'agent)
"\n"))))))
(goto-char (point-min))
(ellie-tree-mode)
(pop-to-buffer buf))))
;;;; ──────────────── Tree commands ────────────────
(defun ellie-tree-toggle ()
"Expand or collapse the item at point."
(interactive)
(let ((key (get-text-property (point) 'ellie-key))
(type (get-text-property (point) 'ellie-type)))
(when (and key (memq type '(header session)))
(if (member key ellie--tree-expanded)
(setq ellie--tree-expanded (remove key ellie--tree-expanded))
(push key ellie--tree-expanded))
(ellie--tree-render))))
(defun ellie-tree-activate ()
"Activate (select) the item at point."
(interactive)
(let ((key (get-text-property (point) 'ellie-key))
(type (get-text-property (point) 'ellie-type))
(parent (get-text-property (point) 'ellie-parent)))
(cond
((eq type 'header)
(ellie-tree-toggle))
((eq type 'session)
(setq ellie--session-id key
ellie--agent-id nil)
(ellie--persist-session key)
(message "Session: %s" key))
((eq type 'agent)
(setq ellie--session-id parent
ellie--agent-id key)
(ellie--persist-session parent)
(message "Agent: %s / %s" parent key)))))
(defun ellie-tree-chat ()
"Open chat buffer for the selected session/agent."
(interactive)
(unless ellie--session-id (user-error "No session selected"))
(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)))
(defun ellie-tree-send-input ()
"Open input window for the current session/agent."
(interactive)
(ellie--open-input nil))
(defun ellie-tree-queue-input ()
"Open input window to queue a prompt."
(interactive)
(ellie--open-input t))
(defun ellie-tree-system-prompt ()
"Show system prompt for the current agent."
(interactive)
(ellie-show-system-prompt))
(defun ellie-tree-new-session ()
"Create a new session and refresh the tree."
(interactive)
(ellie-new-session)
(ellie--tree-render))
(defun ellie-tree-new-agent ()
"Create a new agent in the current session."
(interactive)
(ellie-new-agent)
(ellie--tree-render))
(defun ellie-tree-kill ()
"Kill the session or agent at point."
(interactive)
(let ((type (get-text-property (point) 'ellie-type))
(key (get-text-property (point) 'ellie-key))
(parent (get-text-property (point) 'ellie-parent)))
(cond
((eq type 'session) (ellie-kill-session key))
((eq type 'agent) (ellie-kill-agent parent key)))
(ellie--tree-render)))
(defun ellie-tree-refresh ()
"Refresh the session tree."
(interactive)
(ellie--tree-render))
(define-derived-mode ellie-tree-mode special-mode "Ellie-Tree"
"Major mode for the ollie session tree.
\\{ellie-tree-mode-map}"
:group 'ellie
(buffer-disable-undo))
;;;; ──────────────── Chat display ────────────────
(defvar-local ellie--mode-line-state "unknown"
"Cached session state string for the mode line.")
"Cached agent 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."
"Refresh the chat buffer."
(let* ((buf (get-buffer ellie--buf))
(chat (condition-case nil (ellie--session-file "chat") (error nil)))
(chat (and ellie--session-id ellie--agent-id
(ellie--session-file "chat")))
(raw (and chat (ellie--9p-read chat)))
(size (and raw (length raw))))
(when buf
@ -347,7 +580,6 @@ Also updates the cached mode-line state."
(string-trim 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)
@ -405,9 +637,7 @@ QUEUED non-nil means the prompt will be enqueued, not submitted immediately."
(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))))
(window-height . ,ellie-input-window-height)))))
(defun ellie-input-submit ()
"Submit the contents of the input buffer as a prompt."
@ -439,12 +669,12 @@ QUEUED non-nil means the prompt will be enqueued, not submitted immediately."
(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)
(define-key m (kbd "g") #'ellie-refresh-chat)
(define-key m (kbd "q") #'ellie-tree)
m)
"Keymap for `ellie-mode'.")
@ -458,7 +688,7 @@ QUEUED non-nil means the prompt will be enqueued, not submitted immediately."
(interactive)
(ellie--open-input t))
(defun ellie-refresh ()
(defun ellie-refresh-chat ()
"Force a refresh of the chat log."
(interactive)
(setq ellie--chat-size -1)
@ -466,8 +696,6 @@ QUEUED non-nil means the prompt will be enqueued, not submitted immediately."
(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)
@ -478,7 +706,9 @@ Key bindings:
`("%e"
mode-line-front-space
(:eval
(propertize (format " ellie:%s" (or ellie--session-id "-"))
(propertize (format " ellie:%s/%s"
(or ellie--session-id "-")
(or ellie--agent-id "-"))
'face 'mode-line-buffer-id))
(:eval
(if (string= ellie--mode-line-state "idle")
@ -493,7 +723,7 @@ Key bindings:
;;;; ──────────────── System prompt ────────────────
(defun ellie-show-system-prompt ()
"Display the rendered system prompt for the current session in a buffer."
"Display the rendered system prompt for the current agent."
(interactive)
(let* ((path (ellie--session-file "systemprompt"))
(text (ellie--9p-read path)))
@ -514,161 +744,37 @@ Key bindings:
;;;###autoload
(defun ellie ()
"Open the ollie chat interface.
Attaches to the last session if it still exists; otherwise creates a new one."
"Open the ollie session tree. Attaches to the last known session."
(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))
(if-let ((last (ellie--recall-session)))
(progn
(setq ellie--session-id (car last)
ellie--agent-id (cadr last))
(message "Attached to session %s" (car last)))
(setq ellie--session-id nil
ellie--agent-id nil))
(ellie--tree-render))
;;;###autoload
(defun ellie-tree ()
"Open the session tree buffer."
(interactive)
(ellie--tree-render))
(defun ellie-new-and-open ()
"Create a new ollie session and open the chat buffer."
"Create a new session and open the chat buffer."
(interactive)
(ellie-new-session)
(ellie--open-buf))
(ellie--tree-render))
;;;###autoload
(defun ellie-attach-and-open (id)
"Attach to session ID and open the chat buffer."
(defun ellie-attach-and-open (sid)
"Attach to session SID and open the tree."
(interactive
(let ((ids (ellie--session-ids)))
(let ((ids (ellie--9p-ls "session")))
(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))
(ellie-attach sid)
(ellie--tree-render))
(provide 'ellie)