diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-16 01:48:59 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-16 01:48:59 -0500 |
| commit | 670117cccdbae4706dfaa5e05144c256c3a657f0 (patch) | |
| tree | 481e3e9db64473c26c9aa52502d17e202304a3b6 /modules | |
| parent | a8c7e8bf822535470d1a4621030b0edd07aaccb4 (diff) | |
| download | dotemacs-670117cccdbae4706dfaa5e05144c256c3a657f0.tar.gz dotemacs-670117cccdbae4706dfaa5e05144c256c3a657f0.zip | |
feat(ai-quick-ask): add cj/gptel-quick-ask one-shot command
New module `modules/ai-quick-ask.el`. Bound to `C-; a q` via
`cj/ai-keymap` ("quick ask").
`cj/gptel-quick-ask` reads a prompt in the minibuffer, creates a
transient `*GPTel-Quick*` buffer in `cj/gptel-quick-mode` (a
special-mode derivative with `q` / `escape` / `c` bindings), inserts
"Q: <prompt>" plus a response marker, then calls `gptel-request`
with `:stream t` so the answer streams into the buffer. Doesn't
touch `*AI-Assistant*`, doesn't autosave.
Two follow-up commands work in 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 + response,
seed them into `*AI-Assistant*` under proper org headings (matching
the `cj/gptel--fresh-org-prefix` shape), display the side window,
then dismiss the quick buffer.
13 tests cover the pure helpers (initial-text shape, response
extraction across normal / multi-line / no-marker / empty inputs,
seed-text shape), the ask path (buffer created in right mode,
prompt recorded, gptel-request called, empty-prompt error), the
dismiss path (kills buffer / no-op when absent), and the continue
path (seeds `*AI-Assistant*`, dismisses quick buffer, errors
outside a quick buffer). `gptel-request` is stubbed in tests so
nothing hits the network.
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/ai-config.el | 3 | ||||
| -rw-r--r-- | modules/ai-quick-ask.el | 133 |
2 files changed, 136 insertions, 0 deletions
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 |
