branch: externals/ellama
commit ad88edf9edc313f10107c45a8a21ecf6b6b6d139
Merge: 1c52902d4d f94002a405
Author: Sergey Kostyaev <s-kosty...@users.noreply.github.com>
Commit: GitHub <nore...@github.com>

    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 <sskosty...@gmail.com>
+;; 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 <sskosty...@gmail.com>
 ;; 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

Reply via email to