summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xgithooks/pre-commit2
-rw-r--r--modules/ai-config.el3
-rw-r--r--modules/ai-quick-ask.el133
-rw-r--r--tests/test-ai-quick-ask.el140
-rw-r--r--todo.org40
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
diff --git a/todo.org b/todo.org
index face1e4c..90017155 100644
--- a/todo.org
+++ b/todo.org
@@ -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