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