branch: elpa/gptel commit 6d42fd25915ffdecc8f2490bf1bb586a492417c3 Author: Karthik Chikmagalur <karthikchikmaga...@gmail.com> Commit: Karthik Chikmagalur <karthikchikmaga...@gmail.com>
gptel-transient: Redesign tools menu with two-column layout Redesign the gptel-tools menu to use a two-column layout for tools and categories. The primary reason for the change is that the previous tools menu did not scale to large numbers of tools (> 50), a number very easy to get to with MCP-provided tools. Now only one category of tools is shown at a time. As a bonus, the new menu requires half as many keystrokes as before to enable tools or toggle tool categories. This redesign is inspired by the wonderful proof-of-concept demonstration by @ahmed-shariff in #653. The implementation is necessarily more complex, with a design unlike most Transient prefixes. Some notes: Normally a transient prefix exports its value via transient-args, to be consumed by suffixes, where these args are determined by the state of the menu at the time of export. The gptel-tools menu is dynamic and needs to store tool selections that may not be visible in the meny any more, so we cannot use the transient-args as is. We can not (should not?) control the value of the prefix directly, so we instead use the scope (a secondary value) of the prefix to maintain the history of selections. When running a suffix, we gather tool selections from the scope. The scope is also used as a message channel for connecting the category group and the tool list group for that category. * gptel-transient.el (transient-infix-set, transient-infix-read) (transient-format-value, gptel--switch-category, gptel--switch) (gptel-tools): Redesign menu as described. The `gptel--switch' and `gptel--switch-category' class methods are modified. * gptel-integrations.el (gptel--suffix-mcp-connect, gptel--suffix-mcp-disconnect): Update the state of the gptel-tools menu correctly and carefully after adding or removing tools from MCP servers. * NEWS: Update with mention of new tools menu. --- NEWS | 6 ++ gptel-integrations.el | 25 +++++-- gptel-transient.el | 187 +++++++++++++++++++++++++++++++++++--------------- 3 files changed, 158 insertions(+), 60 deletions(-) diff --git a/NEWS b/NEWS index e1ff220476..4033841882 100644 --- a/NEWS +++ b/NEWS @@ -125,6 +125,12 @@ - The current kill can be added to gptel's context. To enable this, turn on ~gptel-expert-commands~ and use gptel's transient menu. +- The tools menu (~gptel-tools~) has been redesigned. It now displays + tool categories and associated tools in two columns, and it should + scale better to any number of tools. As a bonus, the new menu + requires half as many keystrokes as before to enable individual + tools or toggle categories. + ** Notable Bug fixes - Fix more Org markup conversion edge cases involving nested Markdown diff --git a/gptel-integrations.el b/gptel-integrations.el index 20cea6ce46..bc833a39ed 100644 --- a/gptel-integrations.el +++ b/gptel-integrations.el @@ -211,15 +211,24 @@ from all connected servers if it is nil." :description "Add MCP server tools" :transient t (interactive) + ;; gptel-tools stores its state in its scope slot. Retain the scope but + ;; update it with the newly selected tools. Then set up gptel-tools. (condition-case err (gptel-mcp-connect nil (lambda () (when-let* ((transient--prefix) ((eq (oref transient--prefix command) 'gptel-tools))) - (transient-setup 'gptel-tools))) + (let ((state (transient-scope 'gptel-tools))) + (plist-put state :tools + (delete-dups + (nconc (mapcar (lambda (tool) + (list (gptel-tool-category tool) + (gptel-tool-name tool))) + gptel-tools) + (plist-get state :tools)))) + (transient-setup 'gptel-tools nil nil :scope state)))) t) - (user-error (message "%s" (cadr err)))) - (transient-setup)) + (user-error (message "%s" (cadr err))))) (transient-define-suffix gptel--suffix-mcp-disconnect () "Remove tools provided by MCP servers from gptel." @@ -237,7 +246,15 @@ from all connected servers if it is nil." never v))) (interactive) (call-interactively #'gptel-mcp-disconnect) - (transient-setup)) + ;; gptel-tools stores its state in its scope slot. Retain the scope but + ;; remove tools from it that no longer exist, then set up gptel-tools + (cl-loop with state = (transient-scope 'gptel-tools) + with tools = (plist-get state :tools) + for tool-spec in tools + if (map-nested-elt gptel--known-tools tool-spec) + collect tool-spec into valid-tools + finally do (plist-put state :tools valid-tools) + (transient-setup 'gptel-tools nil nil :scope state))) (transient-remove-suffix 'gptel-tools '(0 2)) (transient-append-suffix 'gptel-tools '(0 -1) diff --git a/gptel-transient.el b/gptel-transient.el index 84a00c0f08..50dff91394 100644 --- a/gptel-transient.el +++ b/gptel-transient.el @@ -253,7 +253,8 @@ Handle formatting for system messages when the active OBJ is a tool-infix of type `gptel--switch'." (when-let* ((name (car (member (oref obj argument) - (mapcar #'gptel-tool-name gptel-tools))))) + (mapcar #'cadr + (plist-get (transient-scope) :tools)))))) (oset obj value (list (oref obj category) name)))) (defvar gptel--crowdsourced-prompts-url @@ -473,11 +474,22 @@ which see." "Class used for arguments that share a category.") (cl-defmethod transient-infix-set ((obj gptel--switch) value) - "The VALUE of a gptel--switch OBJ is a list of the category - and argument, e.g. (\"filesystem\" \"read_file\")." - (if value - (oset obj value (list (oref obj category) value)) - (oset obj value nil))) + "Set VALUE of a `gptel--switch' OBJ. + +It is a list of the category and argument, e.g. + (\"filesystem\" \"read_file\")." + (let ((state (transient-scope)) + (category (oref obj category))) + (if value + (progn + (cl-pushnew (list category value) + (plist-get state :tools) :test #'equal) + (oset obj value (list category value))) + (plist-put state :tools + (delete (list category (oref obj argument)) + (plist-get state :tools))) + (oset obj value nil)) + (oset transient--prefix scope state))) (defclass gptel--switch-category (transient-switch) ((category :initarg :category)) @@ -485,6 +497,34 @@ which see." Their own value is ignored") +(cl-defmethod transient-format-value ((obj gptel--switch-category)) + (let* ((category (oref obj category)) + (active-count + (cl-count-if (lambda (tl) (equal (car tl) category)) + (plist-get (transient-scope) :tools))) + (total-count (length (cdr (assoc category gptel--known-tools))))) + (if (> active-count 0) + (propertize (format "(%d/%d)" active-count total-count) 'face 'transient-value) + (propertize (format "(0/%d)" total-count) 'face 'transient-inactive-value)))) + +;; Pressing a tool category key should have different behaviors in different +;; contexts: +;; - If the tools for the category are not shown, show them, do nothing else +;; - If the tools are showing and any of them are selected, deselect all +;; - If the tools are showing and none of them are selected, select all + +;; To do this we independently track whether the category tools are visible +;; ("active"), and whether any category tools have been "selected": +(cl-defmethod transient-infix-read ((obj gptel--switch-category)) + "Determine OBJ value according to category toggle settings." + (let* ((category (oref obj category)) + (active (equal category (plist-get (transient-scope) :category))) + (selected (cl-some (lambda (tool-spec) (equal category (car tool-spec))) + (plist-get (transient-scope) :tools)))) + (if (not active) + (oref obj value) + (if selected nil (oref obj argument))))) + (cl-defmethod transient-infix-set ((obj gptel--switch-category) value) "When setting VALUE, set all options in the category of OBJ." (dolist (suffix-obj transient--suffixes) @@ -495,10 +535,12 @@ Their own value is ignored") (arg (if (slot-boundp suffix-obj 'argument) (oref suffix-obj argument) (oref obj argument-format)))) - ;; Turn on/off all members in category - (if value + (if value ; Turn on/off all members in category (transient-infix-set suffix-obj arg) (transient-infix-set suffix-obj nil)))) + ;; Update the active menu category and key in the prefix scope + (plist-put (transient-scope) :category (oref obj category)) + (plist-put (transient-scope) :key (oref obj key)) ;; Finally set the "value" of the category itself (oset obj value value)) @@ -880,6 +922,21 @@ together. See `gptel-make-preset' for details." ;; ** Prefix for selecting tools +;; gptel-tools offers a two-level menu for selecting tools, its design is a +;; little convoluted so here's an explanation: +;; +;; Normally a transient prefix exports its value via transient-args, to be +;; consumed by suffixes, where these args are determined by the state of the +;; menu at the time of export. The gptel-tools menu is dynamic and needs to +;; store tool selections that may not be visible in the meny any more, so we +;; cannot use the transient-args. +;; +;; We can not (should not?) control the value of the prefix directly, so we +;; instead use the scope (a secondary value) of the prefix to maintain the +;; history of selections. When running a suffix, we gather tool selections from +;; the scope. The scope is also used as a message channel for connecting the +;; category menu and the tool list menu for that category. + ;;;###autoload (autoload 'gptel-tools "gptel-transient" nil t) (transient-define-prefix gptel-tools () "Select tools to include with gptel requests. @@ -892,6 +949,7 @@ To add tools to this list, use `gptel-make-tool', which see. Using the scope option, you can set tools to use with gptel requests globally, in this buffer or for the next request only (\"oneshot\")." + :refresh-suffixes t [:description "Provide the LLM with tools to run tasks for you" ["" (gptel--infix-variable-scope) @@ -900,65 +958,82 @@ only (\"oneshot\")." (gptel--infix-include-tool-results)] ["" ("RET" "Confirm selection" - (lambda (args) - (interactive (list (transient-args transient-current-command))) - ;; There are two kinds of ARGS: categories with value "(*)", and lists of - ;; the type '("category" "tool_name"). We only want the latter, to use an - ;; index into `gptel--known-backends.' + (lambda (tools) + ;; We don't care about the transient args of this prefix at all, since + ;; the state is managed entirely through its transient-scope: + (interactive (list (plist-get (transient-scope 'gptel-tools) :tools))) (gptel--set-with-scope 'gptel-tools (mapcar (lambda (category-and-name) (map-nested-elt gptel--known-tools category-and-name)) - (cl-delete-if-not #'consp args)) + (cl-delete-if-not #'consp tools)) gptel--set-buffer-locally)) :transient transient--do-return) ("q" "Cancel" transient-quit-one)]] - [:class transient-column - :setup-children - (lambda (_) - (transient-parse-suffixes - 'gptel-tools - (cdr + [[:class transient-column ;Display known categories + :setup-children + (lambda (_) + (transient-parse-suffixes + 'gptel-tools (cl-loop ;loop through gptel--known tools and collect categories for (category . tools-alist) in gptel--known-tools - with unused-keys = (delete ?q (number-sequence ?a ?z)) - for category-key = (seq-find (lambda (k) (member k unused-keys)) category + with unused-keys = (nconc (delete ?q (number-sequence ?a ?z)) + (number-sequence ?0 ?9) + (number-sequence ?A ?Z)) + for category-key = (seq-find (lambda (k) (member k unused-keys)) + (string-remove-prefix "mcp-" category) (seq-first unused-keys)) do (setq unused-keys (delete category-key unused-keys)) - nconc - (cl-loop ;for each category, collect tools as infixes - for (name . tool) in tools-alist - with tool-keys = (delete category-key (nconc (number-sequence ?a ?z) - (number-sequence ?0 ?9))) - for tool-key = (seq-find (lambda (k) (member k tool-keys)) name - (seq-first tool-keys)) - do (setq tool-keys (delete tool-key tool-keys)) - collect ;Each list is a transient infix of type gptel--switch - (list (key-description (list category-key tool-key)) - (concat (make-string (max (- 20 (length name)) 0) ? ) - (propertize - (concat "(" (gptel--describe-directive - (gptel-tool-description tool) (- (window-width) 40)) - ")") - 'face 'shadow)) - (gptel-tool-name tool) - :format " %k %v %d" - :init-value #'gptel--tools-init-value - :class 'gptel--switch - :category category) - into infixes-for-category - finally return - (identity ;TODO(tool): Replace with vconcat for groups separated by category - ;; Add a category header that can be used to toggle all tools in that category - (nconc (list " " (list (key-description (list category-key category-key)) - (concat (propertize (concat category " tools") - 'face 'transient-heading) - (make-string (max (- 14 (length category)) 0) ? )) - "(*)" - :format " %k %d %v" - :class 'gptel--switch-category - :category category)) - infixes-for-category)))))))]) + collect (list (key-description (list category-key)) + (concat (propertize category 'face 'transient-heading) + (make-string (max (- 14 (length category)) 0) ? )) + (char-to-string category-key) + :format " %k %d %v" + :class 'gptel--switch-category + :category category) + into categories + finally do (plist-put (transient-scope) :keys unused-keys) + finally return categories)))] + [:class transient-column ;Display known tools for selected category + :setup-children + (lambda (_) + (transient-parse-suffixes + 'gptel-tools + (when-let* ((category (plist-get (transient-scope) :category)) + (tool-keys (plist-get (transient-scope) :keys))) + (cl-loop ;for each category, collect tools as infixes + with tools-alist = (cdr (assoc category gptel--known-tools)) + for (name . tool) in tools-alist + for tool-key = (seq-find (lambda (k) (member k tool-keys)) name (seq-first tool-keys)) + do (setq tool-keys (delete tool-key tool-keys)) + collect ;Each list is a transient infix of type gptel--switch + (list (key-description (list tool-key)) + (concat (make-string (max (- 20 (length name)) 0) ? ) + (propertize + (concat "(" (gptel--describe-directive + (gptel-tool-description tool) (- (window-width) 40)) + ")") + 'face 'shadow)) + (gptel-tool-name tool) + :format " %k %v %d" + :init-value #'gptel--tools-init-value + :class 'gptel--switch + :category category) + into infixes-for-category + finally return + (cons (list :info + (lambda () (concat + (propertize (plist-get (transient-scope) :key) + 'face 'transient-key) + (propertize " toggle all" 'face 'transient-heading))) + :format " %d") + infixes-for-category)))))]] + (interactive) + (transient-setup + 'gptel-tools nil nil + :scope (list :tools (mapcar (lambda (tool) (list (gptel-tool-category tool) + (gptel-tool-name tool))) + gptel-tools)))) ;; * Transient Infixes