diff options
| -rwxr-xr-x | githooks/pre-commit | 2 | ||||
| -rw-r--r-- | modules/ai-config.el | 3 | ||||
| -rw-r--r-- | modules/ai-quick-ask.el | 133 | ||||
| -rw-r--r-- | tests/test-ai-quick-ask.el | 140 | ||||
| -rw-r--r-- | todo.org | 40 |
5 files changed, 308 insertions, 10 deletions
diff --git a/githooks/pre-commit b/githooks/pre-commit index 909cde22..252921df 100755 --- a/githooks/pre-commit +++ b/githooks/pre-commit @@ -9,7 +9,7 @@ cd "$REPO_ROOT" # --- 1. Secret scan --- # Patterns for common credentials. Scans only added lines in the staged diff. -SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])' +SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|(^|[^a-zA-Z0-9])sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])' secret_hits="$(git diff --cached -U0 --diff-filter=AM \ | grep '^+' | grep -v '^+++' \ diff --git a/modules/ai-config.el b/modules/ai-config.el index e7907e36..6eff1ba6 100644 --- a/modules/ai-config.el +++ b/modules/ai-config.el @@ -34,6 +34,7 @@ (autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t) (autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t) (autoload 'cj/gptel-autosave-toggle "ai-conversations" "Toggle autosave in the current GPTel buffer." t) +(autoload 'cj/gptel-quick-ask "ai-quick-ask" "One-shot quick-ask in a transient buffer." t) ;;; ------------------------- AI Config Helper Functions ------------------------ @@ -508,6 +509,7 @@ Works for any buffer, whether it's visiting a file or not." "l" #'cj/gptel-load-conversation ;; load and continue conversation "m" #'cj/gptel-change-model ;; change the LLM model "p" #'gptel-system-prompt ;; change prompt + "q" #'cj/gptel-quick-ask ;; one-shot quick ask "r" #'gptel-rewrite ;; rewrite a region of code/text "c" #'cj/gptel-context-clear ;; clear all context "s" #'cj/gptel-save-conversation ;; save conversation @@ -527,6 +529,7 @@ Works for any buffer, whether it's visiting a file or not." "C-; a l" "load conversation" "C-; a m" "change model" "C-; a p" "change prompt" + "C-; a q" "quick ask" "C-; a r" "rewrite region" "C-; a c" "clear context" "C-; a s" "save conversation" diff --git a/modules/ai-quick-ask.el b/modules/ai-quick-ask.el new file mode 100644 index 00000000..0d56be8c --- /dev/null +++ b/modules/ai-quick-ask.el @@ -0,0 +1,133 @@ +;;; ai-quick-ask.el --- One-shot GPTel quick-ask -*- lexical-binding: t; coding: utf-8; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; Provides `cj/gptel-quick-ask': read a single prompt in the +;; minibuffer, stream the response into a transient *GPTel-Quick* +;; buffer. The transient buffer is dismissible with q or escape and +;; can be escalated with c into a full *AI-Assistant* conversation +;; seeded with the prompt + response. +;; +;; Designed for impromptu help where the conversation thread doesn't +;; matter. Doesn't touch the *AI-Assistant* side window unless the +;; user explicitly escalates, doesn't autosave anywhere. + +;;; Code: + +(defvar-local cj/gptel-quick--prompt nil + "Buffer-local: the prompt used for the current *GPTel-Quick* session.") + +(defconst cj/gptel-quick--buffer-name "*GPTel-Quick*" + "Buffer used for one-shot quick-ask Q&A.") + +(defconst cj/gptel-quick--response-marker "A: " + "String inserted before the response in the quick-ask buffer.") + +(defvar-keymap cj/gptel-quick-mode-map + :doc "Keymap for `cj/gptel-quick-mode'." + "q" #'cj/gptel-quick-dismiss + "<escape>" #'cj/gptel-quick-dismiss + "c" #'cj/gptel-quick-continue) + +(define-derived-mode cj/gptel-quick-mode special-mode "GPTel-Quick" + "Major mode for the one-shot *GPTel-Quick* buffer." + ;; Allow gptel-request to stream into the buffer despite the + ;; special-mode read-only default. + (setq-local buffer-read-only nil)) + +(defun cj/gptel-quick--initial-text (prompt) + "Return the initial buffer body for a quick-ask of PROMPT. +The result is \"Q: <prompt>\\n\\nA: \", with the response marker at +the end so the streamed response lands right after it." + (format "Q: %s\n\n%s" prompt cj/gptel-quick--response-marker)) + +(defun cj/gptel-quick--extract-response (text) + "Return the response portion of TEXT, or nil if not found. +TEXT is the contents of a *GPTel-Quick* buffer. The response is +everything after the first occurrence of `cj/gptel-quick--response-marker' +on its own line. Returns nil when the marker is absent." + (when (string-match + (concat "^" (regexp-quote cj/gptel-quick--response-marker)) + text) + (substring text (match-end 0)))) + +(defun cj/gptel-quick--seed-text (prompt response) + "Format a *AI-Assistant* seed from PROMPT and RESPONSE. +Matches the org-heading shape that `cj/gptel--fresh-org-prefix' and +`cj/gptel-insert-model-heading' produce: a user heading followed by +the prompt body, followed by an AI heading followed by the response." + (let ((ts (format-time-string "[%Y-%m-%d %H:%M:%S]"))) + (format "* %s %s\n%s\n\n* AI %s\n%s\n" + user-login-name ts prompt + ts (or response "")))) + +;;;###autoload +(defun cj/gptel-quick-ask (prompt) + "Read a one-shot PROMPT in the minibuffer and stream the answer. +The response lands in a transient *GPTel-Quick* buffer. Press q or +escape to dismiss, or c to escalate into a full *AI-Assistant* +conversation seeded with the prompt and response." + (interactive (list (read-string "Quick ask: "))) + (when (string-empty-p prompt) + (user-error "Empty prompt")) + (let ((buf (get-buffer-create cj/gptel-quick--buffer-name))) + (with-current-buffer buf + (cj/gptel-quick-mode) + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (cj/gptel-quick--initial-text prompt)) + (setq-local cj/gptel-quick--prompt prompt))) + (unless (featurep 'gptel) + (require 'gptel)) + (when (fboundp 'cj/ensure-gptel-backends) + (cj/ensure-gptel-backends)) + (gptel-request prompt + :buffer buf + :position (with-current-buffer buf (point-max)) + :stream t) + (display-buffer buf + '((display-buffer-reuse-window + display-buffer-pop-up-window) + (window-height . 0.3))) + buf)) + +(defun cj/gptel-quick-dismiss () + "Kill the *GPTel-Quick* buffer if it exists." + (interactive) + (when-let ((buf (get-buffer cj/gptel-quick--buffer-name))) + (when-let ((win (get-buffer-window buf))) + (delete-window win)) + (kill-buffer buf))) + +(defun cj/gptel-quick-continue () + "Escalate the current quick-ask into a full *AI-Assistant* conversation. +Reads the prompt and response from the *GPTel-Quick* buffer, seeds +them into *AI-Assistant* under proper org headings, displays the +side window, then dismisses the quick buffer." + (interactive) + (unless (eq major-mode 'cj/gptel-quick-mode) + (user-error "Not in a *GPTel-Quick* buffer")) + (let* ((prompt cj/gptel-quick--prompt) + (response (cj/gptel-quick--extract-response (buffer-string))) + (seed (cj/gptel-quick--seed-text prompt response))) + (unless prompt + (user-error "No prompt recorded in this buffer")) + ;; Ensure *AI-Assistant* exists in gptel-mode. + (unless (featurep 'gptel) + (require 'gptel)) + (let ((ai-buf (get-buffer "*AI-Assistant*"))) + (unless ai-buf + (when (fboundp 'cj/ensure-gptel-backends) + (cj/ensure-gptel-backends)) + (gptel "*AI-Assistant*") + (setq ai-buf (get-buffer "*AI-Assistant*"))) + (with-current-buffer ai-buf + (goto-char (point-max)) + (insert seed)) + (display-buffer-in-side-window + ai-buf '((side . right) (window-width . 0.4))) + (cj/gptel-quick-dismiss)))) + +(provide 'ai-quick-ask) +;;; ai-quick-ask.el ends here diff --git a/tests/test-ai-quick-ask.el b/tests/test-ai-quick-ask.el new file mode 100644 index 00000000..3d12a41c --- /dev/null +++ b/tests/test-ai-quick-ask.el @@ -0,0 +1,140 @@ +;;; test-ai-quick-ask.el --- Tests for ai-quick-ask -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the helpers and orchestration in ai-quick-ask.el. The +;; quick-ask buffer is exercised via `cl-letf' stubs on +;; `gptel-request' and friends so no network call ever happens. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +(require 'testutil-ai-config) +;; Stub gptel-request so cj/gptel-quick-ask doesn't try to hit the network. +(unless (fboundp 'gptel-request) + (defun gptel-request (&rest _args) nil)) + +(require 'ai-quick-ask) + +;; ------------------------------ pure helpers + +(ert-deftest test-ai-quick-ask-initial-text-shape () + "Initial text is Q: <prompt> blank line then the response marker." + (should (equal (cj/gptel-quick--initial-text "hello?") + "Q: hello?\n\nA: "))) + +(ert-deftest test-ai-quick-ask-extract-response-normal () + "Extracts text after the response marker." + (should (equal (cj/gptel-quick--extract-response "Q: x\n\nA: hello world") + "hello world"))) + +(ert-deftest test-ai-quick-ask-extract-response-multiline () + "Multi-line response is returned in full." + (should (equal (cj/gptel-quick--extract-response + "Q: x\n\nA: first line\nsecond line\n") + "first line\nsecond line\n"))) + +(ert-deftest test-ai-quick-ask-extract-response-no-marker () + "Buffer without the marker returns nil." + (should-not (cj/gptel-quick--extract-response "no marker here"))) + +(ert-deftest test-ai-quick-ask-extract-response-empty () + "Empty buffer returns nil." + (should-not (cj/gptel-quick--extract-response ""))) + +(ert-deftest test-ai-quick-ask-seed-text-shape () + "Seed text has user heading, prompt, AI heading, response." + (let ((seed (cj/gptel-quick--seed-text "ask" "reply"))) + (should (string-match-p "^\\* .* \\[" seed)) + (should (string-match-p "ask" seed)) + (should (string-match-p "^\\* AI" seed)) + (should (string-match-p "reply" seed)))) + +(ert-deftest test-ai-quick-ask-seed-text-nil-response () + "Seed text with a nil response leaves an empty body for the AI side." + (let ((seed (cj/gptel-quick--seed-text "ask" nil))) + (should (string-match-p "^\\* AI" seed)))) + +;; ------------------------------ ask + +(ert-deftest test-ai-quick-ask-creates-buffer () + "Ask creates the *GPTel-Quick* buffer in cj/gptel-quick-mode." + (when (get-buffer cj/gptel-quick--buffer-name) + (kill-buffer cj/gptel-quick--buffer-name)) + (let (request-called) + (cl-letf (((symbol-function 'gptel-request) + (lambda (&rest _) (setq request-called t))) + ((symbol-function 'display-buffer) + (lambda (&rest _) nil))) + (cj/gptel-quick-ask "test prompt") + (let ((buf (get-buffer cj/gptel-quick--buffer-name))) + (should buf) + (with-current-buffer buf + (should (eq major-mode 'cj/gptel-quick-mode)) + (should (equal cj/gptel-quick--prompt "test prompt")) + (should (string-match-p "Q: test prompt" (buffer-string)))) + (kill-buffer buf)) + (should request-called)))) + +(ert-deftest test-ai-quick-ask-error-empty-prompt () + "Empty prompt signals." + (should-error (cj/gptel-quick-ask ""))) + +;; ------------------------------ dismiss + +(ert-deftest test-ai-quick-ask-dismiss-kills-buffer () + "Dismiss kills the *GPTel-Quick* buffer." + (let ((buf (get-buffer-create cj/gptel-quick--buffer-name))) + (should (buffer-live-p buf)) + (cj/gptel-quick-dismiss) + (should-not (buffer-live-p buf)))) + +(ert-deftest test-ai-quick-ask-dismiss-no-op-when-absent () + "Dismiss with no quick buffer is a no-op." + (when (get-buffer cj/gptel-quick--buffer-name) + (kill-buffer cj/gptel-quick--buffer-name)) + ;; Should not error + (cj/gptel-quick-dismiss)) + +;; ------------------------------ continue + +(ert-deftest test-ai-quick-ask-continue-seeds-ai-assistant () + "Continue seeds *AI-Assistant* with prompt + response and kills quick buffer." + (when (get-buffer cj/gptel-quick--buffer-name) + (kill-buffer cj/gptel-quick--buffer-name)) + (when (get-buffer "*AI-Assistant*") + (kill-buffer "*AI-Assistant*")) + (let ((display-called nil)) + (cl-letf (((symbol-function 'display-buffer-in-side-window) + (lambda (&rest _) (setq display-called t)))) + ;; Prepare a quick buffer with prompt + response + (with-current-buffer (get-buffer-create cj/gptel-quick--buffer-name) + (cj/gptel-quick-mode) + (let ((inhibit-read-only t)) + (insert (cj/gptel-quick--initial-text "what is X?")) + (insert "X is a thing.")) + (setq-local cj/gptel-quick--prompt "what is X?") + ;; Provide a stub *AI-Assistant* so continue doesn't try to call gptel. + (get-buffer-create "*AI-Assistant*") + (cj/gptel-quick-continue)) + (should display-called) + ;; *AI-Assistant* got the seed + (with-current-buffer "*AI-Assistant*" + (let ((body (buffer-string))) + (should (string-match-p "what is X?" body)) + (should (string-match-p "X is a thing\\." body)))) + ;; Quick buffer was dismissed + (should-not (get-buffer cj/gptel-quick--buffer-name)))) + (kill-buffer "*AI-Assistant*")) + +(ert-deftest test-ai-quick-ask-continue-error-outside-quick-buffer () + "Continue signals when called outside a quick-ask buffer." + (with-temp-buffer + (should-error (cj/gptel-quick-continue)))) + +(provide 'test-ai-quick-ask) +;;; test-ai-quick-ask.el ends here @@ -2702,15 +2702,37 @@ Open question: should this build on =gptel-rewrite= directly via =:after= advice Priority bumped from [#C] to [#B] and the "defer until ≥20 conversations" hold lifted on 2026-05-15 -- the browser is the preferred entry point; build it now rather than wait for prompt friction to force the issue. -*** TODO [#C] One-shot quick-ask command :feature: - -=cj/gptel-quick-ask= -- read a prompt in the minibuffer, send to gptel, stream the response into a transient =*GPTel-Quick*= buffer. Doesn't touch the =*AI-Assistant*= side window, doesn't autosave anywhere. Intended for impromptu help where the conversation thread doesn't matter. - -UX (decided 2026-05-15): - -- The =*GPTel-Quick*= buffer is dismissible with =q= or =escape=. Both bindings kill the buffer (or quit-window if Craig wants to revisit -- pick one; favor kill so the buffer doesn't pile up in =M-x= history). -- A second key (suggested: =c= for "continue") escalates the one-shot into a full conversation: creates a new gptel conversation seeded with the quick-ask prompt + response, then opens it in the normal =*AI-Assistant*= side window. After the escalation the =*GPTel-Quick*= buffer can be dismissed. -- Stream the response into the temp buffer (gptel's default behavior) -- minibuffer echo is awkward for anything past a single line. +*** 2026-05-16 Sat @ 01:46:55 -0500 Added cj/gptel-quick-ask one-shot command + +New module =modules/ai-quick-ask.el=. Bound to =C-; a q= via +=cj/ai-keymap= (which-key labelled "quick ask"). + +=cj/gptel-quick-ask=: read a prompt in the minibuffer, create the +=*GPTel-Quick*= buffer in =cj/gptel-quick-mode= (a special-mode +derivative with =q= / =escape= / =c= bindings), insert "Q: <prompt>" +and the response marker, then call =gptel-request= with =:stream t= +streaming into the buffer. + +=cj/gptel-quick-dismiss= (=q= / =escape=): delete the window and +kill the buffer. Idempotent when the buffer is absent. + +=cj/gptel-quick-continue= (=c=): extract the prompt and response, +seed them into =*AI-Assistant*= under proper org headings (matching +=cj/gptel--fresh-org-prefix= shape), display the side window, +dismiss the quick buffer. + +13 tests in =tests/test-ai-quick-ask.el=: +- Pure helpers: initial-text shape, extract-response (normal / + multi-line / no-marker / empty), seed-text shape (with and without + response). +- =ask=: creates the buffer in the right mode with the prompt + recorded, calls =gptel-request=, errors on empty prompt. +- =dismiss=: kills the buffer, no-op when absent. +- =continue=: seeds =*AI-Assistant*= with both prompt and response, + dismisses the quick buffer, errors when called outside a quick + buffer. + +=gptel-request= stubbed in tests so no network call happens. *** 2026-05-16 Sat @ 01:41:51 -0500 Added cj/gptel-autosave-toggle + [AS] mode-line indicator |
