From 670117cccdbae4706dfaa5e05144c256c3a657f0 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 16 May 2026 01:48:59 -0500 Subject: 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: " 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. --- modules/ai-config.el | 3 ++ modules/ai-quick-ask.el | 133 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 modules/ai-quick-ask.el (limited to 'modules') 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 + +;;; 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 + "" #'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: \\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 -- cgit v1.2.3