diff --git a/contrib/elisp/ellie.el b/contrib/elisp/ellie.el index 591be6c..4d4a3bc 100644 --- a/contrib/elisp/ellie.el +++ b/contrib/elisp/ellie.el @@ -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)