branch: externals/ellama
commit ad88edf9edc313f10107c45a8a21ecf6b6b6d139
Merge: 1c52902d4d f94002a405
Author: Sergey Kostyaev <[email protected]>
Commit: GitHub <[email protected]>
Merge pull request #238 from s-kostyaev/ux-improvements
Improve transient menu
---
NEWS.org | 7 ++
README.org | 20 +++++
ellama-community-prompts.el | 200 ++++++++++++++++++++++++++++++++++++++++++++
ellama.el | 172 +++++++++++++++++++++++++++----------
tests/test-ellama.el | 8 --
5 files changed, 355 insertions(+), 52 deletions(-)
diff --git a/NEWS.org b/NEWS.org
index 9ec0c38ba8..98e0d56657 100644
--- a/NEWS.org
+++ b/NEWS.org
@@ -1,3 +1,10 @@
+* Version 1.4.0
+- Improved transient menus.
+- Added ellama-session-kill functionality.
+- Added community prompt collection feature.
+- Refactored ~ellama-context-mode~ to be a major mode.
+- Added functionality to remove context elements by name.
+- Added option to always show context line in header or mode line.
* Version 1.3.0
- Implemented ellama context header line and mode line features.
- Added ~ellama-context-header-line-mode~, ~ellama-context-mode-line-mode~ and
diff --git a/README.org b/README.org
index 1bda4fb572..205216a3dc 100644
--- a/README.org
+++ b/README.org
@@ -250,6 +250,10 @@ Delete ellama session.
Change current active session.
+*** ellama-session-kill
+
+Select and kill one of active sessions.
+
*** ellama-session-rename
Rename current ellama session.
@@ -320,6 +324,16 @@ provides much better results on reasoning tasks using AoT.
Solve domain specific problem with simple chain. It makes LLMs act
like a professional and adds a planning step.
+*** ellama-community-prompts-select-blueprint
+
+Select a prompt from the community prompt collection.
+The user is prompted to choose a role, and then a
+corresponding prompt is inserted into a blueprint buffer.
+
+*** ellama-community-prompts-update-variables
+
+Prompt user for values of variables found in current buffer and update them.
+
** Keymap
In any buffer where there is active ellama streaming, you can press
@@ -444,6 +458,12 @@ argument generated text string.
~display-buffer-same-window~.
- ~ellama-preview-context-element-display-action-function~: Display
action function for ~ellama-preview-context-element~.
+- ~ellama-context-line-always-visible~: Make context header or mode line always
+ visible, even with empty context.
+- ~ellama-community-prompts-url~: The URL of the community prompts collection.
+- ~ellama-community-prompts-file~: Path to the CSV file containing community
prompts.
+ This file is expected to be located inside an ~ellama~ subdirectory
+ within your ~user-emacs-directory~.
** Minor modes
diff --git a/ellama-community-prompts.el b/ellama-community-prompts.el
new file mode 100644
index 0000000000..c4f1aebc05
--- /dev/null
+++ b/ellama-community-prompts.el
@@ -0,0 +1,200 @@
+;;; ellama-community-prompts.el --- Community prompt collection -*-
lexical-binding: t; package-lint-main-file: "ellama.el"; -*-
+
+;; Copyright (C) 2023-2025 Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;; This file is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 3, or (at your option)
+;; any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;;
+;; Ellama is a tool for interacting with large language models from Emacs.
+;; It allows you to ask questions and receive responses from the
+;; LLMs. Ellama can perform various tasks such as translation, code
+;; review, summarization, enhancing grammar/spelling or wording and
+;; more through the Emacs interface. Ellama natively supports streaming
+;; output, making it effortless to use with your preferred text editor.
+;;
+
+;;; Code:
+(require 'plz)
+(require 'ellama)
+
+(defcustom ellama-community-prompts-url
"https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv"
+ "The URL of the community prompts collection."
+ :type 'string
+ :group 'ellama)
+
+(defcustom ellama-community-prompts-file (expand-file-name
+ "community-prompts.csv"
+ (file-name-concat
+ user-emacs-directory
+ "ellama"))
+ "Path to the CSV file containing community prompts.
+This file is expected to be located inside an `ellama' subdirectory
+within your `user-emacs-directory'."
+ :type 'file
+ :group 'ellama)
+
+(defun ellama-community-prompts-ensure-file ()
+ "Ensure that the community prompt collection file is downloaded.
+Downloads the file from `ellama-community-prompts-url` if it does
+not already exist."
+ (unless (file-exists-p ellama-community-prompts-file)
+ (let* ((directory (file-name-directory ellama-community-prompts-file))
+ (response (plz 'get ellama-community-prompts-url
+ :as 'file
+ :then (lambda (filename)
+ (rename-file filename
ellama-community-prompts-file t))
+ :else (lambda (error)
+ (message "Failed to download community prompts:
%s" error)))))
+ (when (and response (not (file-directory-p directory)))
+ (make-directory directory t))
+ (when response
+ (message "Community prompts file downloaded successfully.")))))
+
+(defun ellama-community-prompts-parse-csv-line (line)
+ "Parse a single CSV LINE into a list of fields, handling quotes.
+LINE is the string to be parsed."
+ (let ((i 0)
+ (len (length line)))
+ (cl-loop
+ with fields = '()
+ with current-field = ""
+ with inside-quotes = nil
+ while (< i len)
+ do (let ((char (aref line i)))
+ (cond
+ ;; Opening quote (start of field)
+ ((and (eq char ?\") (not inside-quotes))
+ (setq inside-quotes t)
+ (cl-incf i))
+ ;; Closing quote (end of field or escaped quote)
+ ((and (eq char ?\") inside-quotes)
+ (if (and (< (1+ i) len) (eq (aref line (1+ i)) ?\"))
+ (progn ; Escaped quote: add single quote, skip next character
+ (setq current-field (concat current-field "\""))
+ (cl-incf i 2))
+ (setq inside-quotes nil) ; End of quoted field
+ (cl-incf i)))
+ ;; Comma separator (outside quotes)
+ ((and (eq char ?,) (not inside-quotes))
+ (push current-field fields)
+ (setq current-field "")
+ (cl-incf i))
+ ;; Regular character
+ (t
+ (setq current-field (concat current-field (string char)))
+ (cl-incf i))))
+ ;; Add the last field after loop ends
+ finally return (nreverse (cons current-field fields)))))
+
+(defun ellama-community-prompts-convert-to-plist (parsed-line)
+ "Convert PARSED-LINE to plist.
+PARSED-LINE is expected to be a list with three elements: :act,
+:prompt, and :for-devs."
+ (let ((act (cl-first parsed-line))
+ (prompt (cl-second parsed-line))
+ (for-devs (string= "TRUE" (cl-third parsed-line))))
+ `(:act ,act :prompt ,prompt :for-devs ,for-devs)))
+
+(defvar ellama-community-prompts-collection nil
+ "Community prompts collection.")
+
+(defun ellama-community-prompts-ensure ()
+ "Ensure that the community prompt collection are loaded and available.
+This function ensures that the file specified by
`ellama-community-prompts-file'
+is read and parsed, and the resulting collection of prompts is stored in
+`ellama-community-prompts-collection'. If the collection is already populated,
+this function does nothing.
+
+Returns the collection of community prompts."
+ (ellama-community-prompts-ensure-file)
+ (unless ellama-community-prompts-collection
+ (setq ellama-community-prompts-collection
+ (let ((buf (find-file-noselect ellama-community-prompts-file)))
+ (with-current-buffer buf
+ (mapcar (lambda (line)
+ (ellama-community-prompts-convert-to-plist
+ (ellama-community-prompts-parse-csv-line
+ line)))
+ (cdr (string-lines
+ (buffer-substring-no-properties
+ (point-min) (point-max)))))))))
+ ellama-community-prompts-collection)
+
+(defvar ellama-community-prompts-blurpint-buffer "
*ellama-community-prompts-blueprint-buffer*"
+ "Buffer for community prompt blueprint.")
+
+;;;###autoload
+(defun ellama-community-prompts-select-blueprint (&optional for-devs)
+ "Select a prompt from the community prompt collection.
+The user is prompted to choose a role, and then a
+corresponding prompt is inserted into a blueprint buffer.
+
+Optional argument FOR-DEVS filters prompts for developers."
+ (interactive "P")
+ (let ((acts '())
+ selected-act selected-prompt)
+ ;; Collect unique acts from the filtered collection
+ (dolist (prompt (ellama-community-prompts-ensure))
+ (when (or (not for-devs) (eq for-devs (plist-get prompt :for-devs)))
+ (cl-pushnew (plist-get prompt :act) acts)))
+ ;; Prompt user to select an act
+ (setq selected-act (completing-read "Select Act: " acts))
+ ;; Find the corresponding prompt
+ (catch 'found-prompt
+ (dolist (prompt ellama-community-prompts-collection)
+ (when (and (string= selected-act (plist-get prompt :act))
+ (or (not for-devs) (eq for-devs (plist-get prompt
:for-devs))))
+ (setq selected-prompt (plist-get prompt :prompt))
+ (throw 'found-prompt nil))))
+ ;; Create a new buffer and insert the selected prompt
+ (with-current-buffer (get-buffer-create
ellama-community-prompts-blurpint-buffer)
+ (erase-buffer)
+ (let ((hard-newline t))
+ (insert selected-prompt)
+ (fill-region (point-min) (point-max))
+ (ellama-blueprint-mode))
+ (switch-to-buffer (current-buffer))
+ (ellama-community-prompts-update-variables))))
+
+(defun ellama-community-prompts-get-variable-list ()
+ "Return a deduplicated list of variables found in the current buffer."
+ (save-excursion
+ (let ((vars '()))
+ (goto-char (point-min))
+ (while (re-search-forward "\{\\([^}]+\\)}" nil t)
+ (push (match-string 1) vars))
+ (seq-uniq vars))))
+
+(defun ellama-community-prompts-set-variable (var value)
+ "Replace VAR with VALUE in blueprint buffer."
+ (save-excursion
+ (goto-char (point-min))
+ (while (search-forward (format "{%s}" var) nil t)
+ (replace-match value))))
+
+;;;###autoload
+(defun ellama-community-prompts-update-variables ()
+ "Prompt user for values of variables found in current buffer and update
them."
+ (interactive)
+ (let ((vars (ellama-community-prompts-get-variable-list)))
+ (dolist (var vars)
+ (let ((value (read-string (format "Enter value for {%s}: " var))))
+ (ellama-community-prompts-set-variable var value)))))
+
+(provide 'ellama-community-prompts)
+;;; ellama-community-prompts.el ends here.
diff --git a/ellama.el b/ellama.el
index e6171a1922..444a9769b1 100644
--- a/ellama.el
+++ b/ellama.el
@@ -5,8 +5,8 @@
;; Author: Sergey Kostyaev <[email protected]>
;; URL: http://github.com/s-kostyaev/ellama
;; Keywords: help local tools
-;; Package-Requires: ((emacs "28.1") (llm "0.22.0") (transient "0.7") (compat
"29.1"))
-;; Version: 1.3.0
+;; Package-Requires: ((emacs "28.1") (llm "0.22.0") (plz "0.8") (transient
"0.7") (compat "29.1"))
+;; Version: 1.4.0
;; SPDX-License-Identifier: GPL-3.0-or-later
;; Created: 8th Oct 2023
@@ -131,6 +131,11 @@ Make reasoning models more useful for many cases."
:group 'ellama
:type 'boolean)
+(defcustom ellama-context-line-always-visible nil
+ "Make context header or mode line always visible, even with empty context."
+ :group 'ellama
+ :type 'boolean)
+
(defcustom ellama-command-map
(let ((map (make-sparse-keymap)))
;; code
@@ -997,6 +1002,16 @@ If EPHEMERAL non nil new session will not be associated
with any file."
(display-buffer buffer (when ellama-chat-display-action-function
`((ignore .
(,ellama-chat-display-action-function)))))))
+;;;###autoload
+(defun ellama-session-kill ()
+ "Select and kill one of active sessions."
+ (interactive)
+ (let* ((id (completing-read
+ "Select session to kill: "
+ (hash-table-keys ellama--active-sessions)))
+ (buffer (ellama-get-session-buffer id)))
+ (kill-buffer buffer)))
+
;;;###autoload
(defun ellama-session-rename ()
"Rename current ellama session."
@@ -1052,6 +1067,27 @@ If EPHEMERAL non nil new session will not be associated
with any file."
(erase-buffer))
(ellama-update-context-show))
+(defun ellama-context--element-remove-by-name (name)
+ "Remove all context element that matches by NAME."
+ (setq ellama--global-context
+ (cl-remove-if (lambda (el)
+ (string= name (ellama-context-element-display el)))
+ ellama--global-context)))
+
+;;;###autoload
+(defun ellama-context-element-remove-by-name ()
+ "Remove a context element by its name from the global context.
+This function prompts the user to select a context element from
+the list of unique elements currently present in the global
+context and removes it. After removal, it updates the display of
+the context."
+ (interactive)
+ (ellama-context--element-remove-by-name
+ (completing-read
+ "Remove context element: "
+ (seq-uniq (mapcar #'ellama-context-element-display
ellama--global-context))))
+ (ellama-update-context-show))
+
;; Context elements
(defclass ellama-context-element () ()
@@ -1085,15 +1121,16 @@ If EPHEMERAL non nil new session will not be associated
with any file."
(declare-function posframe-hide "ext:posframe")
(with-current-buffer ellama--context-buffer
(erase-buffer)
- (when ellama--global-context
- (insert (format
- " ellama ctx: %s"
- (string-join
- (mapcar
- (lambda (el)
- (ellama-context-element-display el))
- ellama--global-context)
- " ")))))
+ (if ellama--global-context
+ (insert (format
+ " ellama ctx: %s"
+ (string-join
+ (mapcar
+ (lambda (el)
+ (ellama-context-element-display el))
+ ellama--global-context)
+ " ")))
+ (insert " ellama ctx")))
(when ellama-context-posframe-enabled
(require 'posframe)
(if ellama--global-context
@@ -1130,7 +1167,8 @@ If EPHEMERAL non nil new session will not be associated
with any file."
(add-hook 'window-state-change-hook #'ellama-context-update-header-line)
(if ellama-context-header-line-mode
(ellama-context-update-header-line)
- (setq header-line-format (delete '(:eval (ellama-context-line))
header-line-format))))
+ (when (listp header-line-format)
+ (setq header-line-format (delete '(:eval (ellama-context-line))
header-line-format)))))
;;;###autoload
(define-globalized-minor-mode ellama-context-header-line-global-mode
@@ -1139,9 +1177,12 @@ If EPHEMERAL non nil new session will not be associated
with any file."
(defun ellama-context-update-header-line ()
"Update and display context information in the header line."
- (if (and ellama-context-header-line-mode ellama--global-context)
- (add-to-list 'header-line-format '(:eval (ellama-context-line)) t)
- (setq header-line-format (delete '(:eval (ellama-context-line))
header-line-format))))
+ (when (listp header-line-format)
+ (if (and ellama-context-header-line-mode
+ (or ellama-context-line-always-visible
+ ellama--global-context))
+ (add-to-list 'header-line-format '(:eval (ellama-context-line)) t)
+ (setq header-line-format (delete '(:eval (ellama-context-line))
header-line-format)))))
;;;###autoload
(define-minor-mode ellama-context-mode-line-mode
@@ -1165,7 +1206,9 @@ If EPHEMERAL non nil new session will not be associated
with any file."
(defun ellama-context-update-mode-line ()
"Update and display context information in the mode line."
- (if (and ellama-context-mode-line-mode ellama--global-context)
+ (if (and ellama-context-mode-line-mode
+ (or ellama-context-line-always-visible
+ ellama--global-context))
(add-to-list 'mode-line-format '(:eval (ellama-context-line)) t)
(setq mode-line-format (delete '(:eval (ellama-context-line))
mode-line-format))))
@@ -1197,30 +1240,60 @@ If EPHEMERAL non nil new session will not be associated
with any file."
"d" #'ellama-remove-context-element-at-point
"RET" #'ellama-preview-context-element-at-point)
-(define-minor-mode ellama-context-mode
+(define-derived-mode ellama-context-mode
+ fundamental-mode
+ "ellama-ctx"
"Toggle Ellama Context mode."
:keymap ellama-context-mode-map
:group 'ellama)
;;;###autoload
-(defun ellama-manage-context ()
- "Manage the global context."
+(defun ellama-send-buffer-to-new-chat ()
+ "Send current buffer to new chat session."
(interactive)
+ (ellama-chat
+ (buffer-substring-no-properties (point-min) (point-max))
+ t))
+
+(defvar-keymap ellama-blueprint-mode-map
+ :doc "Local keymap for Ellama blueprint mode buffers."
+ :parent global-map
+ "C-c C-c" #'ellama-send-buffer-to-new-chat
+ "C-c C-k" (lambda () (interactive) (kill-buffer (current-buffer))))
+
+;;;###autoload
+(define-derived-mode ellama-blueprint-mode
+ fundamental-mode
+ "ellama-blueprint"
+ "Toggle Ellama Blueprint mode."
+ :keymap ellama-blueprint-mode-map
+ :group 'ellama
+ (setq header-line-format
+ "'C-c C-c' to send 'C-c C-k' to cancel"))
+
+(defun ellama-update-context-buffer ()
+ "Update ellama context buffer."
(let* ((buf (get-buffer-create ellama-context-buffer))
(inhibit-read-only t))
(with-current-buffer buf
(read-only-mode +1)
- (ellama-context-mode +1)
+ (ellama-context-mode)
(erase-buffer)
(dolist (el ellama--global-context)
(insert (ellama-context-element-display el))
(put-text-property (pos-bol) (pos-eol) 'context-element el)
(insert "\n"))
- (goto-char (point-min))
- (display-buffer
- buf
- (when ellama-manage-context-display-action-function
- `((ignore . (,ellama-manage-context-display-action-function))))))))
+ (goto-char (point-min)))))
+
+;;;###autoload
+(defun ellama-manage-context ()
+ "Manage the global context."
+ (interactive)
+ (ellama-update-context-buffer)
+ (display-buffer
+ ellama-context-buffer
+ (when ellama-manage-context-display-action-function
+ `((ignore . (,ellama-manage-context-display-action-function))))))
(defvar-keymap ellama-preview-context-mode-map
:doc "Local keymap for Ellama preview context mode buffers."
@@ -2941,6 +3014,7 @@ Call CALLBACK on result list of strings. ARGS contains
keys for fine control.
("f" "Load from provider" ellama-transient-model-get-from-provider
:transient t)
("F" "Load from current session"
ellama-transient-model-get-from-current-session
+ :description (lambda () (format "Load from current session (%s)"
ellama--current-session-id))
:transient t)
("m" "Set Model" ellama-transient-set-ollama-model
:transient t
@@ -3022,7 +3096,8 @@ Call CALLBACK on result list of strings. ARGS contains
keys for fine control.
("l" "Load Session" ellama-load-session)
("r" "Rename Session" ellama-session-rename)
("d" "Delete Session" ellama-session-delete)
- ("a" "Activate Session" ellama-session-switch)]
+ ("a" "Activate Session" ellama-session-switch)
+ ("k" "Kill Session" ellama-session-kill)]
["Quit" ("q" "Quit" transient-quit-one)]])
(transient-define-prefix ellama-transient-improve-menu ()
@@ -3060,37 +3135,46 @@ Call CALLBACK on result list of strings. ARGS contains
keys for fine control.
(transient-define-prefix ellama-transient-context-menu ()
"Context Commands."
- [["Context Commands"
+ ["Context Commands"
+ :description (lambda ()
+ (ellama-update-context-buffer)
+ (format "Current context:
+%s" (with-current-buffer ellama-context-buffer
+ (buffer-substring (point-min) (point-max)))))
+ ["Add"
("b" "Add Buffer" ellama-context-add-buffer)
("d" "Add Directory" ellama-context-add-directory)
("f" "Add File" ellama-context-add-file)
("s" "Add Selection" ellama-context-add-selection)
- ("i" "Add Info Node" ellama-context-add-info-node)
+ ("i" "Add Info Node" ellama-context-add-info-node)]
+ ["Manage"
("m" "Manage context" ellama-manage-context)
+ ("D" "Delete element" ellama-context-element-remove-by-name)
("r" "Context reset" ellama-context-reset)]
["Quit" ("q" "Quit" transient-quit-one)]])
(transient-define-prefix ellama-transient-main-menu ()
"Main Menu."
- [["Main"
- ("c" "Chat" ellama-chat)
- ("w" "Write" ellama-write)
+ ["Main"
+ [("c" "Chat" ellama-chat)
+ ("B" "Chat with community blueprint"
ellama-community-prompts-select-blueprint)]
+ [("a" "Ask Commands" ellama-transient-ask-menu)
+ ("C" "Code Commands" ellama-transient-code-menu)]]
+ ["Text"
+ [("w" "Write" ellama-write)
("P" "Proofread" ellama-proofread)
- ("a" "Ask Commands" ellama-transient-ask-menu)
- ("C" "Code Commands" ellama-transient-code-menu)
- ("o" "Ollama model" ellama-select-ollama-model)]]
- [["Text"
- ("s" "Summarize Commands" ellama-transient-summarize-menu)
- ("i" "Improve Commands" ellama-transient-improve-menu)
- ("t" "Translate Commands" ellama-transient-translate-menu)
- ("m" "Make Commands" ellama-transient-make-menu)
("k" "Text Complete" ellama-complete)
("g" "Text change" ellama-change)
- ("d" "Define word" ellama-define-word)]]
- [["System"
- ("S" "Session Commands" ellama-transient-session-menu)
- ("x" "Context Commands" ellama-transient-context-menu)
- ("p" "Provider selection" ellama-provider-select)]]
+ ("d" "Define word" ellama-define-word)]
+ [("s" "Summarize Commands" ellama-transient-summarize-menu)
+ ("i" "Improve Commands" ellama-transient-improve-menu)
+ ("t" "Translate Commands" ellama-transient-translate-menu)
+ ("m" "Make Commands" ellama-transient-make-menu)]]
+ ["System"
+ [("o" "Ollama model" ellama-select-ollama-model)
+ ("p" "Provider selection" ellama-provider-select)]
+ [("S" "Session Commands" ellama-transient-session-menu)
+ ("x" "Context Commands" ellama-transient-context-menu)]]
[["Problem solving"
("R" "Solve reasoning problem" ellama-solve-reasoning-problem)
("D" "Solve domain specific problem"
ellama-solve-domain-specific-problem)]]
diff --git a/tests/test-ellama.el b/tests/test-ellama.el
index 6b47022191..6f695afe0c 100644
--- a/tests/test-ellama.el
+++ b/tests/test-ellama.el
@@ -415,14 +415,6 @@ Snake case helps improve readability, especially in
languages that are sensitive
(ellama--fix-file-name "a/\\?%*:|\"<>.;=")
"a_____________")))
-(ert-deftest ellama-context-minor-mode-test ()
- (with-temp-buffer
- (should-not ellama-context-mode)
- (ellama-context-mode 1)
- (should ellama-context-mode)
- (ellama-context-mode -1)
- (should-not ellama-context-mode)))
-
(provide 'test-ellama)
;;; test-ellama.el ends here