diff options
| -rw-r--r-- | modules/ai-term.el | 85 | ||||
| -rw-r--r-- | modules/jumper.el | 16 | ||||
| -rw-r--r-- | tests/test-ai-term--f9-in-term.el | 58 | ||||
| -rw-r--r-- | tests/test-ai-term--keybindings.el | 59 | ||||
| -rw-r--r-- | tests/test-ai-term--next-no-agents.el | 34 |
5 files changed, 146 insertions, 106 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el index ff8da0035..1c98dd5ee 100644 --- a/modules/ai-term.el +++ b/modules/ai-term.el @@ -77,6 +77,7 @@ (require 'cj-window-geometry-lib) (require 'cj-window-toggle-lib) (require 'host-environment) +(require 'keybindings) ;; provides cj/register-prefix-map (C-; a) (declare-function ghostel "ghostel" (&optional arg)) (declare-function ghostel-send-string "ghostel" (string)) @@ -991,11 +992,12 @@ The queue is the live agent buffers ordered by buffer name -- a stable rotation, unaffected by which agent was most recently selected. When an agent window is on screen, swap it to the next agent in the queue \(wrapping after the last) and select it. When no agent is displayed but -agents exist, show the first. Signals `user-error' when none are open. +agents exist, show the first. When none are open, open the project picker +to launch the first agent rather than erroring. -Bound to s-<f9>. Unlike <f9> (toggle the most-recent agent on/off), this -is the \"switch among existing agents\" surface; C-<f9> opens the project -picker and M-<f9> closes an agent." +Bound to M-SPC. Unlike C-; a a (toggle the most-recent agent on/off), this +is the \"switch among existing agents\" surface; C-; a s opens the project +picker and C-; a k closes an agent." (interactive) (let* ((buffers (sort (cj/--ai-term-agent-buffers) (lambda (a b) @@ -1003,41 +1005,48 @@ picker and M-<f9> closes an agent." (win (cj/--ai-term-displayed-agent-window)) (current (and win (window-buffer win))) (next (cj/--ai-term-next-agent-buffer current buffers))) - (unless next - (user-error "No AI-term agent buffers open")) - (if win - (progn - (set-window-buffer win next) - (select-window win)) - (display-buffer next) - (let ((w (get-buffer-window next))) - (when w (select-window w)))) - (message "Agent: %s" (buffer-name next)))) - -(keymap-global-set "<f9>" #'cj/ai-term) -(keymap-global-set "C-<f9>" #'cj/ai-term-pick-project) -(keymap-global-set "s-<f9>" #'cj/ai-term-next) -(keymap-global-set "M-<f9>" #'cj/ai-term-close) - -;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to -;; the terminal program, so a plain <f9> typed while point is inside an agent -;; buffer would be sent to the program instead of toggling the agent -- which -;; bites hard when the agent buffer is the only window in the frame. Re-bind -;; the F9 family in `ghostel-mode-map' so the toggle reaches Emacs from there -;; too. (C-<f9> / M-<f9> are bound here as well so the behaviour is uniform.) + (if (not next) + ;; No agents open: launch the first via the project picker instead of + ;; erroring, so the swap key doubles as a "start an agent" key. + (cj/ai-term-pick-project) + (if win + (progn + (set-window-buffer win next) + (select-window win)) + (display-buffer next) + (let ((w (get-buffer-window next))) + (when w (select-window w)))) + (message "Agent: %s" (buffer-name next))))) + +;; ai-term lives under the C-; a prefix (vacated when gptel was archived). +;; The frequent "swap to the next agent" also gets M-SPC for a fast chord. +(defvar-keymap cj/ai-term-keymap + :doc "Keymap for ai-term agent commands (C-; a)." + "a" #'cj/ai-term ;; toggle the most-recent agent on/off + "s" #'cj/ai-term-pick-project ;; select / launch via the project picker + "n" #'cj/ai-term-next ;; swap to the next open agent + "k" #'cj/ai-term-close) ;; kill the current agent +(cj/register-prefix-map "a" cj/ai-term-keymap "ai-term") +(keymap-global-set "M-SPC" #'cj/ai-term-next) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; a" "ai-term menu" + "C-; a a" "toggle agent" + "C-; a s" "select / launch" + "C-; a n" "next agent" + "C-; a k" "kill agent" + "M-SPC" "ai-term: next agent")) + +;; In ghostel's semi-char mode, keys not in `ghostel-keymap-exceptions' are +;; forwarded to the pty, and `ghostel-semi-char-mode-map' outranks the major +;; mode map. M-SPC (swap to the next agent) must reach Emacs from inside an +;; agent buffer, so add it to the exceptions, rebuild the semi-char map, and +;; bind it in `ghostel-mode-map'. C-; is already an exception (term-config), +;; so the C-; a family resolves through the global prefix without extra wiring. (with-eval-after-load 'ghostel - (keymap-set ghostel-mode-map "<f9>" #'cj/ai-term) - (keymap-set ghostel-mode-map "C-<f9>" #'cj/ai-term-pick-project) - (keymap-set ghostel-mode-map "s-<f9>" #'cj/ai-term-next) - (keymap-set ghostel-mode-map "M-<f9>" #'cj/ai-term-close) - ;; The bindings above live in `ghostel-mode-map', but in semi-char mode - ;; ghostel's own `ghostel-semi-char-mode-map' forwards every key not in - ;; `ghostel-keymap-exceptions' to the pty -- and that map outranks the - ;; major-mode map, so it would swallow the F9 family before the bindings - ;; above fire. Add the family to the exceptions and rebuild the semi-char - ;; map so the keys fall through to `ghostel-mode-map' inside agent buffers. - (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>")) - (add-to-list 'ghostel-keymap-exceptions key)) + (keymap-set ghostel-mode-map "M-SPC" #'cj/ai-term-next) + (add-to-list 'ghostel-keymap-exceptions "M-SPC") (ghostel--rebuild-semi-char-keymap)) ;; ---------- emacsclient: keep opened files off the agent terminal ---------- diff --git a/modules/jumper.el b/modules/jumper.el index d5d0cf7a7..3dc00aa18 100644 --- a/modules/jumper.el +++ b/modules/jumper.el @@ -303,16 +303,12 @@ Returns: \\='no-locations if no locations stored, (interactive) (keymap-global-set jumper-prefix-key jumper-map)) -;; Call jumper-setup-keys when the package is loaded -(jumper-setup-keys) - -;; which-key integration -(with-eval-after-load 'which-key - (which-key-add-key-based-replacements - "M-SPC" "jumper menu" - "M-SPC SPC" "store location" - "M-SPC j" "jump to location" - "M-SPC d" "remove location")) +;; Jumper's M-SPC prefix was removed 2026-06-23 so M-SPC could go to +;; `cj/ai-term-next'. A cleverer home for jumper (numbers or F-keys) is +;; pending review; until then its commands are reachable via M-x +;; (jumper-store-location / jumper-jump-to-location / jumper-remove-location). +;; To re-home: set `jumper-prefix-key' to the new prefix and call +;; `jumper-setup-keys' (and restore the which-key labels for that prefix). (provide 'jumper) ;;; jumper.el ends here. diff --git a/tests/test-ai-term--f9-in-term.el b/tests/test-ai-term--f9-in-term.el deleted file mode 100644 index 0477f2517..000000000 --- a/tests/test-ai-term--f9-in-term.el +++ /dev/null @@ -1,58 +0,0 @@ -;;; test-ai-term--f9-in-term.el --- F9 reaches Emacs from inside an agent buffer -*- lexical-binding: t; -*- - -;;; Commentary: -;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to -;; the terminal program, so a plain <f9> typed while point is in an agent -;; buffer would be sent to the program instead of toggling the agent -- exactly -;; the case when the agent buffer fills the frame. `ai-term.el' re-binds the F9 -;; family in `ghostel-mode-map'. These tests require ghostel (which defines -;; `ghostel-mode-map' and lets ai-term's `with-eval-after-load' fire) BEFORE -;; ai-term, then confirm the bindings landed (and the global ones are intact). -;; `(require 'ghostel)' does not load the native module, so this stays light. - -;;; Code: - -(require 'ert) -(require 'package) - -(setq package-user-dir (expand-file-name "elpa" user-emacs-directory)) -(package-initialize) -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ghostel) -(require 'ai-term) - -(ert-deftest test-ai-term-f9-bound-in-ghostel-mode-map () - "Normal: <f9> in `ghostel-mode-map' runs the agent toggle." - (should (eq (keymap-lookup ghostel-mode-map "<f9>") #'cj/ai-term))) - -(ert-deftest test-ai-term-f9-family-bound-in-ghostel-mode-map () - "Normal: the C-/s-/M- F9 variants are bound in `ghostel-mode-map' too. -`s-<f9>' steps to the next agent; `M-<f9>' closes an agent via -`cj/ai-term-close'." - (should (eq (keymap-lookup ghostel-mode-map "C-<f9>") #'cj/ai-term-pick-project)) - (should (eq (keymap-lookup ghostel-mode-map "s-<f9>") #'cj/ai-term-next)) - (should (eq (keymap-lookup ghostel-mode-map "M-<f9>") #'cj/ai-term-close))) - -(ert-deftest test-ai-term-f9-still-bound-globally () - "Normal: the global F9 family bindings are intact. -`<f9>' toggles the ai-term agent window; `C-<f9>' picks a project -agent; `s-<f9>' steps to the next agent; `M-<f9>' closes an agent -via `cj/ai-term-close'." - (should (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term)) - (should (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project)) - (should (eq (lookup-key (current-global-map) (kbd "s-<f9>")) #'cj/ai-term-next)) - (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close))) - -(ert-deftest test-ai-term-f9-family-in-keymap-exceptions () - "Regression: the F9 family is in `ghostel-keymap-exceptions' so semi-char -mode lets it reach Emacs instead of forwarding it to the terminal program. -Binding in `ghostel-mode-map' alone is not enough -- the semi-char map outranks -it and forwards any key not in the exceptions to the pty." - (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>")) - (should (member key ghostel-keymap-exceptions))) - ;; The rebuilt semi-char map must no longer forward <f9> to the pty. - (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f9>") - 'ghostel--send-event))) - -(provide 'test-ai-term--f9-in-term) -;;; test-ai-term--f9-in-term.el ends here diff --git a/tests/test-ai-term--keybindings.el b/tests/test-ai-term--keybindings.el new file mode 100644 index 000000000..a8b92ffa8 --- /dev/null +++ b/tests/test-ai-term--keybindings.el @@ -0,0 +1,59 @@ +;;; test-ai-term--keybindings.el --- ai-term keybinding placement -*- lexical-binding: t; -*- + +;;; Commentary: +;; ai-term lives under the C-; a prefix (vacated when gptel was archived), with +;; the frequent "swap to the next agent" also on M-SPC for a fast chord. M-SPC +;; must reach Emacs from inside an agent buffer, so it is bound in +;; `ghostel-mode-map' and added to `ghostel-keymap-exceptions' (the semi-char +;; map otherwise forwards it to the pty). C-; is already an exception via +;; term-config, so the C-; a family resolves through the global prefix. These +;; tests require ghostel (so ai-term's `with-eval-after-load' fires) before +;; ai-term, then confirm the bindings landed and the old F9 family is gone. +;; `(require 'ghostel)' does not load the native module, so this stays light. + +;;; Code: + +(require 'ert) +(require 'package) + +(setq package-user-dir (expand-file-name "elpa" user-emacs-directory)) +(package-initialize) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ghostel) +(require 'ai-term) + +(ert-deftest test-ai-term-keymap-leaf-bindings () + "Normal: the ai-term keymap binds toggle/select/next/kill on a/s/n/k." + (should (eq (keymap-lookup cj/ai-term-keymap "a") #'cj/ai-term)) + (should (eq (keymap-lookup cj/ai-term-keymap "s") #'cj/ai-term-pick-project)) + (should (eq (keymap-lookup cj/ai-term-keymap "n") #'cj/ai-term-next)) + (should (eq (keymap-lookup cj/ai-term-keymap "k") #'cj/ai-term-close))) + +(ert-deftest test-ai-term-keymap-registered-under-custom-prefix () + "Normal: the ai-term keymap is registered under C-; a." + (should (eq (keymap-lookup cj/custom-keymap "a") cj/ai-term-keymap))) + +(ert-deftest test-ai-term-next-bound-to-meta-space-globally () + "Normal: M-SPC runs `cj/ai-term-next' (the fast swap chord)." + (should (eq (lookup-key (current-global-map) (kbd "M-SPC")) #'cj/ai-term-next))) + +(ert-deftest test-ai-term-meta-space-bound-in-ghostel-mode-map () + "Normal: M-SPC is bound in `ghostel-mode-map' so swap works inside an agent." + (should (eq (keymap-lookup ghostel-mode-map "M-SPC") #'cj/ai-term-next))) + +(ert-deftest test-ai-term-meta-space-in-keymap-exceptions () + "Regression: M-SPC is in `ghostel-keymap-exceptions' so semi-char mode lets it +reach Emacs instead of forwarding it to the pty." + (should (member "M-SPC" ghostel-keymap-exceptions)) + (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "M-SPC") + 'ghostel--send-event))) + +(ert-deftest test-ai-term-f9-family-removed-globally () + "Regression: the old F9 family no longer binds the ai-term commands globally." + (should-not (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term)) + (should-not (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project)) + (should-not (eq (lookup-key (current-global-map) (kbd "s-<f9>")) #'cj/ai-term-next)) + (should-not (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close))) + +(provide 'test-ai-term--keybindings) +;;; test-ai-term--keybindings.el ends here diff --git a/tests/test-ai-term--next-no-agents.el b/tests/test-ai-term--next-no-agents.el new file mode 100644 index 000000000..ef87d71ee --- /dev/null +++ b/tests/test-ai-term--next-no-agents.el @@ -0,0 +1,34 @@ +;;; test-ai-term--next-no-agents.el --- cj/ai-term-next no-agents fallback -*- lexical-binding: t; -*- + +;;; Commentary: +;; When no agent buffers are open, `cj/ai-term-next' (bound to M-SPC) launches +;; the project picker (`cj/ai-term-pick-project') to start the first agent, +;; instead of signalling a `user-error'. The swap key thus doubles as a +;; "start an agent" key when there is nothing to swap to. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(ert-deftest test-ai-term-next-no-agents-launches-picker () + "Error: no agents open -> launches the picker instead of erroring." + (let ((picked 0)) + (cl-letf (((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil)) + ((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&rest _) nil)) + ((symbol-function 'cj/ai-term-pick-project) (lambda (&rest _) (setq picked (1+ picked))))) + (cj/ai-term-next) + (should (= picked 1))))) + +(ert-deftest test-ai-term-next-no-agents-does-not-signal () + "Error: no agents open -> returns normally, no user-error raised." + (cl-letf (((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil)) + ((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&rest _) nil)) + ((symbol-function 'cj/ai-term-pick-project) (lambda (&rest _) nil))) + (should (progn (cj/ai-term-next) t)))) + +(provide 'test-ai-term--next-no-agents) +;;; test-ai-term--next-no-agents.el ends here |
