aboutsummaryrefslogtreecommitdiff
path: root/modules/ai-quick-ask.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ai-quick-ask.el')
-rw-r--r--modules/ai-quick-ask.el133
1 files changed, 133 insertions, 0 deletions
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