diff --git a/HISTORY.org b/HISTORY.org index b606720..f860893 100644 --- a/HISTORY.org +++ b/HISTORY.org @@ -2,6 +2,7 @@ * Release history ** Main branch change +- Feat: Supporting search content inside task org files - Feat: Add current-branch PR creation option - Feat: Add optional numbered next-step suggestions for discussion prompts via `ai-code-discussion-auto-follow-up-enabled` - Reuse GPTel prompt classification when available, add a transient toggle on `C-c a F`, and record the follow-up suffix in prompt history diff --git a/ai-code-backends.el b/ai-code-backends.el index 79ca5c7..8eb1eb3 100644 --- a/ai-code-backends.el +++ b/ai-code-backends.el @@ -616,13 +616,13 @@ ACTION should be the symbol `install' or `uninstall'." ;;;###autoload (defun ai-code-install-backend-skills () - "Install skills for the currently selected backend. -If the backend defines an :install-skills property, use it: + "Install or uninstall skills for the currently selected backend. +Prompt for whether to install or uninstall first. +When installing, if the backend defines an :install-skills property, use it: - string: run as a shell command via `compile'. - symbol: call the function. -Otherwise fall back to prompting the AI session to install from a -skills repository URL." - ;; DONE: firstly ask user if it is install or uninstall, then ask for the repo URL if needed, and finally run install / uninstall using corresponding prompt. +Otherwise, or when uninstalling, fall back to prompting the AI session +to manage skills from a skills repository URL." (interactive) (let* ((spec (ai-code--backend-spec ai-code-selected-backend))) (if (not spec) diff --git a/ai-code-git.el b/ai-code-git.el index 1fce412..de145bf 100644 --- a/ai-code-git.el +++ b/ai-code-git.el @@ -49,6 +49,8 @@ Candidate values: (declare-function ai-code--git-root "ai-code-file" (&optional dir)) (defvar ai-code-files-dir-name) +(defvar ai-code-pr-title-history nil + "Minibuffer history for optional PR titles.") (defun ai-code--git-ignored-repo-file-p (file root) "Return non-nil when FILE should be ignored for repo candidates under ROOT." @@ -183,7 +185,7 @@ branch for the current branch PR. Otherwise, ask for the relevant pull request or issue URL." (let* ((review-mode (ai-code--pull-or-review-pr-mode-choice)) (init-prompt - (if (eq review-mode 'send-current-branch-pr) + (if (eq review-mode 'send-current-branch-pr) (progn (unless (magit-toplevel) (user-error "Not inside a Git repository")) @@ -192,9 +194,14 @@ request or issue URL." (ai-code--default-pr-target-branch current-branch)) (target-branch (ai-code-read-string "Target branch to merge into: " - default-target-branch))) + default-target-branch)) + (pr-title + (read-string + "PR title (optional, leave empty for AI to generate): " + nil + 'ai-code-pr-title-history))) (ai-code--build-send-current-branch-pr-init-prompt - review-source current-branch target-branch))) + review-source current-branch target-branch pr-title))) (let* ((url-prompt (ai-code--pull-or-review-url-prompt review-mode)) (target-url (ai-code-read-string url-prompt))) (ai-code--build-pr-init-prompt review-source target-url review-mode)))) @@ -207,6 +214,7 @@ request or issue URL." (defun ai-code--pull-or-review-pr-mode-choice () "Prompt user to choose analysis mode for a pull request or issue." ;; DONE: add a choice: send out PR for current branch. The feature will ask user the target branch to merge. By default, it should be parent branch of current branch. AI should send out PR with description. The description should looks like it's written by the author, and it should be short. + ;; DONE: for send out PR feature, it should ask user about the PR title. If user does not provide one, AI should generate a concise title based on the code change (let* ((review-mode-alist '(("Review the PR" . review-pr) ("Check unresolved feedback" . check-feedback) ("Investigate issue" . investigate-issue) @@ -298,24 +306,32 @@ request or issue URL." "Create the pull request using the backend's PR creation capability. " "Do not treat this as a PR review flow before the PR exists.")))) -(defun ai-code--build-send-current-branch-pr-init-prompt (review-source current-branch target-branch) - "Build a PR creation prompt for REVIEW-SOURCE, CURRENT-BRANCH, and TARGET-BRANCH." +(defun ai-code--build-send-current-branch-pr-init-prompt + (review-source current-branch target-branch &optional pr-title) + "Build a PR creation prompt. +REVIEW-SOURCE, CURRENT-BRANCH, TARGET-BRANCH, and PR-TITLE +define the PR request." (let ((source-instruction - (ai-code--send-current-branch-pr-source-instruction review-source))) + (ai-code--send-current-branch-pr-source-instruction review-source)) + (title-instruction + (if (string-empty-p (or pr-title "")) + "2. Generate a concise PR title based on the code change.\n" + (format "2. Use this PR title exactly: %s\n" pr-title)))) (format "Create a pull request from branch %s into %s. %s PR Creation Steps: 1. Inspect the current branch changes and open or send out a pull request into %s. -2. Write a concise PR description that sounds like it was written by the author, but do not make it too short. -3. Keep the description focused on the problem, the approach, and the most important verification, with enough detail for reviewers to understand the change quickly. -4. Aim for a compact but complete description, roughly a short summary plus 2 to 3 brief supporting paragraphs or bullet points. -5. Return the final PR URL and the exact description that was used." +%s3. Write a concise PR description that sounds like it was written by the author, but do not make it too short. +4. Keep the description focused on the problem, the approach, and the most important verification, with enough detail for reviewers to understand the change quickly. +5. Aim for a compact but complete description, roughly a short summary plus 2 to 3 brief supporting paragraphs or bullet points. +6. Return the final PR URL, the exact PR title, and the exact description that were used." current-branch target-branch source-instruction - target-branch))) + target-branch + title-instruction))) ;;;###autoload (defun ai-code-pull-or-review-diff-file () diff --git a/ai-code-prompt-mode.el b/ai-code-prompt-mode.el index abb3be4..c144fe5 100644 --- a/ai-code-prompt-mode.el +++ b/ai-code-prompt-mode.el @@ -500,6 +500,9 @@ based on the task name. Otherwise, use cleaned-up task name directly." :type 'boolean :group 'ai-code) +(defvar ai-code-task-search-directory-history nil + "Minibuffer history for task content search directories.") + (defun ai-code--get-files-directory () "Get the task directory path. If in a git repository, return `.ai.code.files/` under git root. @@ -656,38 +659,76 @@ Returns the selected directory path." (when (member task-name task-file-candidates) (expand-file-name task-name ai-code-files-dir))) +(defun ai-code--read-task-search-directory (ai-code-files-dir) + "Read a target directory for searching task content. +Default to AI-CODE-FILES-DIR and keep a dedicated directory history." + (let* ((input + (read-string "Directory to search org files: " + ai-code-files-dir + 'ai-code-task-search-directory-history + ai-code-files-dir)) + (target-dir (if (string-empty-p input) + ai-code-files-dir + (expand-file-name input ai-code-files-dir)))) + (unless (file-directory-p target-dir) + (user-error "Search directory does not exist: %s" target-dir)) + target-dir)) + +(defun ai-code--build-task-search-prompt (target-dir search-description) + "Build a prompt for searching org files in TARGET-DIR. +SEARCH-DESCRIPTION describes what content the AI should search for." + (format + (concat + "Search the content of all .org files recursively under directory: %s\n" + "Search target description: %s\n" + "Focus on matching content inside the files, not just file names.\n" + "Return the relevant file paths, matched excerpts, and a concise summary.") + target-dir + search-description)) + +(defun ai-code--search-task-files-with-ai (ai-code-files-dir) + "Prompt for task file search inputs and send a search request to AI." + (let* ((target-dir (ai-code--read-task-search-directory ai-code-files-dir)) + (search-description (ai-code-read-string "Search description for .org files: ")) + (default-prompt (ai-code--build-task-search-prompt target-dir search-description)) + (confirmed-prompt (ai-code-read-string "Confirm search prompt: " default-prompt))) + (ai-code--execute-command confirmed-prompt))) + ;;;###autoload -(defun ai-code-create-or-open-task-file () +(defun ai-code-create-or-open-task-file (&optional arg) "Create or open an AI task file. Prompts for a task name. If empty, opens the task directory. If non-empty, optionally prompts for a URL, generates a filename -using GPTel, and creates the task file." - (interactive) - (let* ((ai-code-files-dir (ai-code--ensure-files-directory)) - (task-file-candidates (ai-code--task-file-candidates ai-code-files-dir)) - (task-name (ai-code--read-task-name task-file-candidates)) - (existing-task-file (ai-code--existing-task-file-path task-name task-file-candidates ai-code-files-dir))) - (cond - ((string-empty-p task-name) - (dired-other-window ai-code-files-dir) - (message "Opened task directory: %s" ai-code-files-dir)) - (existing-task-file - (ai-code--open-or-create-task-file existing-task-file task-name task-name "")) - (t - (let* ((task-url (read-string "URL (optional, press Enter to skip): ")) - (generated-filename (ai-code--generate-task-filename task-name)) - (confirmed-filename (read-string "Confirm task filename (end with / to create subdirectory): " generated-filename)) - (current-dir (expand-file-name default-directory)) - (selected-dir (ai-code--select-task-target-directory ai-code-files-dir current-dir)) - (create-dir-only-p (string-suffix-p "/" confirmed-filename)) - (task-file (expand-file-name confirmed-filename selected-dir))) - (if create-dir-only-p - (let ((subdir (expand-file-name (directory-file-name confirmed-filename) selected-dir))) - (unless (file-directory-p subdir) - (make-directory subdir t)) - (dired-other-window subdir) - (message "Opened directory: %s" subdir)) - (ai-code--open-or-create-task-file task-file confirmed-filename task-name task-url))))))) +using GPTel, and creates the task file. +With prefix ARG, prompt AI to search org file content under a target directory." + (interactive "P") + (let ((ai-code-files-dir (ai-code--ensure-files-directory))) + (if arg + (ai-code--search-task-files-with-ai ai-code-files-dir) + (let* ((task-file-candidates (ai-code--task-file-candidates ai-code-files-dir)) + (task-name (ai-code--read-task-name task-file-candidates)) + (existing-task-file (ai-code--existing-task-file-path task-name task-file-candidates ai-code-files-dir))) + (cond + ((string-empty-p task-name) + (dired-other-window ai-code-files-dir) + (message "Opened task directory: %s" ai-code-files-dir)) + (existing-task-file + (ai-code--open-or-create-task-file existing-task-file task-name task-name "")) + (t + (let* ((task-url (read-string "URL (optional, press Enter to skip): ")) + (generated-filename (ai-code--generate-task-filename task-name)) + (confirmed-filename (read-string "Confirm task filename (end with / to create subdirectory): " generated-filename)) + (current-dir (expand-file-name default-directory)) + (selected-dir (ai-code--select-task-target-directory ai-code-files-dir current-dir)) + (create-dir-only-p (string-suffix-p "/" confirmed-filename)) + (task-file (expand-file-name confirmed-filename selected-dir))) + (if create-dir-only-p + (let ((subdir (expand-file-name (directory-file-name confirmed-filename) selected-dir))) + (unless (file-directory-p subdir) + (make-directory subdir t)) + (dired-other-window subdir) + (message "Opened directory: %s" subdir)) + (ai-code--open-or-create-task-file task-file confirmed-filename task-name task-url))))))))) ;;;###autoload (add-to-list 'auto-mode-alist diff --git a/ai-code.el b/ai-code.el index ed255b6..d6ad634 100644 --- a/ai-code.el +++ b/ai-code.el @@ -234,13 +234,13 @@ test suffixes." ;;;###autoload (defcustom ai-code-next-step-suggestion-suffix (concat - "At the end of your response, provide 2-3 numbered candidate next\n" - "steps. Keep each option to one sentence. Mark the single best option\n" - "with \"(Recommended)\". If the user replies with only a number such\n" - "as 1, 2, or 3, treat that as selecting the corresponding next step\n" - "from your previous answer. The user may also ignore these options\n" - "and send a different follow-up request instead. Do not suggest code\n" - "changes unless they are clearly warranted by the discussion.") + "At the end of your response, provide 3-4 numbered candidate next\n" + "steps. Keep each option to one sentence. At least 2 candidates must\n" + "be AI-actionable items as follow up: either a code change or tool usage. Mark the\n" + "single best option with \"(Recommended)\". If the user replies with\n" + "only a number such as 1, 2, 3, or 4, treat that as selecting the\n" + "corresponding next step from your previous answer. The user may also\n" + "ignore these options and send a different follow-up request instead.") "Prompt suffix for numbered next-step suggestions in discussion prompts." :type '(choice (const nil) string) :group 'ai-code) @@ -532,7 +532,7 @@ Shows the current backend label to the right." ("v" "GitHub PR AI Action" ai-code-pull-or-review-diff-file) ("!" "Run Current File or Command" ai-code-run-current-file-or-shell-cmd) ("b" "Build/Test/Lint (AI follow-up)" ai-code-build-or-test-project) - ("K" "Create or open task file" ai-code-create-or-open-task-file) + ("K" "Create/Open task file (C-u: Search)" ai-code-create-or-open-task-file) ("n" "Take notes from AI session region" ai-code-take-notes) (":" "Speech to text input" ai-code-speech-to-text-input)) diff --git a/test/test_ai-code-backends.el b/test/test_ai-code-backends.el index 3ed8dea..297ccbe 100644 --- a/test/test_ai-code-backends.el +++ b/test/test_ai-code-backends.el @@ -297,6 +297,13 @@ (should (string-match-p "superpowers" sent-command)) (should (string-match-p "uninstall" sent-command))))) +(ert-deftest ai-code-test-install-backend-skills-docstring-covers-uninstall () + "Docstring should describe install and uninstall behavior." + (let ((doc (documentation #'ai-code-install-backend-skills))) + (should (string-match-p "Install or uninstall skills" doc)) + (should (string-match-p "install-skills property" doc)) + (should (string-match-p "uninstall" doc)))) + (ert-deftest ai-code-test-manage-backend-skills-fallback-errors-on-invalid-action () "Fallback helper should reject invalid backend skills actions." (cl-letf (((symbol-function 'read-string) @@ -380,11 +387,10 @@ :type 'user-error)))) (ert-deftest ai-code-test-claude-code-backend-has-install-skills () - "Claude Code backend spec should have :install-skills set to the dedicated function." + "Claude Code backend spec should use the generic skills fallback." (let ((spec (ai-code--backend-spec 'claude-code))) (should spec) - (should (eq (plist-get (cdr spec) :install-skills) - 'ai-code-claude-code-install-skills)))) + (should-not (plist-get (cdr spec) :install-skills)))) (provide 'test_ai-code-backends) diff --git a/test/test_ai-code-git.el b/test/test_ai-code-git.el index b7ff9a9..7d7bc00 100644 --- a/test/test_ai-code-git.el +++ b/test/test_ai-code-git.el @@ -301,7 +301,7 @@ When .gitignore is missing some entries, they should be added." (ert-deftest ai-code-test-pull-or-review-pr-with-source-send-current-branch-pr-uses-neutral-prompt () "Current branch PR flow should validate repo and use a PR creation prompt label." - (let (captured-read-prompts captured-inserted-prompt) + (let (captured-read-prompts captured-read-string-prompts captured-inserted-prompt) (cl-letf (((symbol-function 'completing-read) (lambda (&rest _args) "Send out PR for current branch")) ((symbol-function 'magit-toplevel) @@ -319,14 +319,23 @@ When .gitignore is missing some entries, they should be added." ((string= prompt "Enter PR creation prompt: ") initial-input) (t initial-input)))) + ((symbol-function 'read-string) + (lambda (prompt &optional initial-input _history _default-value &rest _args) + (push prompt captured-read-string-prompts) + (if (string= prompt "PR title (optional, leave empty for AI to generate): ") + "" + initial-input))) ((symbol-function 'ai-code--insert-prompt) (lambda (prompt) (setq captured-inserted-prompt prompt)))) (ai-code--pull-or-review-pr-with-source 'gh-cli) (should (member "Target branch to merge into: " captured-read-prompts)) + (should (member "PR title (optional, leave empty for AI to generate): " + captured-read-string-prompts)) (should (member "Enter PR creation prompt: " captured-read-prompts)) (should-not (member "Enter review prompt: " captured-read-prompts)) - (should (string-match-p "feature/improve-pr-flow" captured-inserted-prompt))))) + (should (string-match-p "feature/improve-pr-flow" captured-inserted-prompt)) + (should (string-match-p "generate a concise pr title" (downcase captured-inserted-prompt)))))) (ert-deftest ai-code-test-pull-or-review-diff-file-prepare-pr-description-github-mcp () "When choosing PR description mode, prompt should ask AI to draft a PR description." diff --git a/test/test_ai-code-prompt-mode.el b/test/test_ai-code-prompt-mode.el index d20eed5..3bb2e37 100644 --- a/test/test_ai-code-prompt-mode.el +++ b/test/test_ai-code-prompt-mode.el @@ -281,6 +281,71 @@ and ensures everything is cleaned up afterward." (when (file-directory-p files-dir) (delete-directory files-dir t))))) +(ert-deftest ai-code-test-create-or-open-task-file-with-prefix-sends-search-prompt () + "Test that prefix arg sends a confirmed search prompt to the AI session." + (ai-code-with-test-repo + (let* ((files-dir (expand-file-name ".ai.code.files" git-root)) + (search-dir (expand-file-name "notes" files-dir)) + (read-calls nil) + (sent-command nil) + (switch-called nil)) + (make-directory search-dir t) + (unwind-protect + (progn + (cl-letf (((symbol-function 'read-string) + (lambda (prompt &optional initial-input history default-value _inherit) + (push (list prompt initial-input history default-value) read-calls) + (if (string-match-p "Directory to search" prompt) + search-dir + (ert-fail (format "Unexpected prompt: %s" prompt))))) + ((symbol-function 'ai-code-read-string) + (lambda (prompt &optional initial-input candidate-list) + (push (list prompt initial-input candidate-list) read-calls) + (cond + ((string-match-p "Search description" prompt) "find todos about auth") + ((string-match-p "Confirm search prompt" prompt) initial-input) + (t (ert-fail (format "Unexpected prompt: %s" prompt)))))) + ((symbol-function 'ai-code-cli-send-command) + (lambda (command) + (setq sent-command command))) + ((symbol-function 'ai-code-cli-switch-to-buffer) + (lambda () + (setq switch-called t))) + ((symbol-function 'message) + (lambda (&rest _args) nil))) + (let ((current-prefix-arg '(4))) + (call-interactively #'ai-code-create-or-open-task-file)) + (should switch-called) + (should (equal (car (car (last read-calls))) + "Directory to search org files: ")) + (should (equal (nth 1 (car (last read-calls))) files-dir)) + (should (eq (nth 2 (car (last read-calls))) + 'ai-code-task-search-directory-history)) + (should (equal sent-command + (concat + "Search the content of all .org files recursively under directory: " + search-dir + "\n" + "Search target description: find todos about auth" + "\n" + "Focus on matching content inside the files, not just file names." + "\n" + "Return the relevant file paths, matched excerpts, and a concise summary."))))) + (when (file-directory-p files-dir) + (delete-directory files-dir t)))))) + +(ert-deftest ai-code-test-read-task-search-directory-expands-relative-input-from-files-dir () + "Relative search directories should resolve from AI-CODE-FILES-DIR." + (let* ((ai-code-files-dir "/tmp/project/.ai.code.files/") + (expected-dir (expand-file-name "notes" ai-code-files-dir))) + (cl-letf (((symbol-function 'read-string) + (lambda (&rest _args) "notes")) + ((symbol-function 'file-directory-p) + (lambda (dir) + (string= dir expected-dir)))) + (should (equal (ai-code--read-task-search-directory ai-code-files-dir) + expected-dir))))) + (ert-deftest ai-code-test-create-or-open-task-file-create-new () "Test that ai-code-create-or-open-task-file creates new task file with metadata." (ai-code-with-test-repo diff --git a/test/test_ai-code.el b/test/test_ai-code.el index 6dfab13..560bf56 100644 --- a/test/test_ai-code.el +++ b/test/test_ai-code.el @@ -205,7 +205,11 @@ ((symbol-function 'ai-code--read-auto-follow-up-choice) (lambda () t))) (should (string-match-p - "2-3 numbered candidate next[[:space:]\n]+steps" + "3-4 numbered candidate next[[:space:]\n]+steps" + (ai-code--resolve-auto-follow-up-suffix-for-send + "Explain this function"))) + (should (string-match-p + "At least 2 candidates must[[:space:]\n]+be AI-actionable items" (ai-code--resolve-auto-follow-up-suffix-for-send "Explain this function")))))) @@ -236,12 +240,12 @@ (lambda () t))) (let ((this-command 'ai-code-ask-question)) (should (string-match-p - "The user may also ignore these options" + "The user may also[[:space:]\n]+ignore these options" (ai-code--resolve-auto-follow-up-suffix-for-send "Explain this function")))) (let ((this-command 'ai-code-send-command)) (should (string-match-p - "The user may also ignore these options" + "The user may also[[:space:]\n]+ignore these options" (ai-code--resolve-auto-follow-up-suffix-for-send "Summarize this design"))))))) @@ -265,9 +269,14 @@ (lambda (&rest _args) nil))) (ai-code--write-prompt-to-file-and-send "Explain this function") (should (string-match-p "BASE SUFFIX" sent-command)) - (should (string-match-p "2-3 numbered candidate next[[:space:]\n]+steps" + (should (string-match-p "3-4 numbered candidate next[[:space:]\n]+steps" sent-command)) - (should (string-match-p "If the user replies with only a number" sent-command))))) + (should (string-match-p + "At least 2 candidates must[[:space:]\n]+be AI-actionable items" + sent-command)) + (should (string-match-p + "If the user replies with[[:space:]\n]+only a number" + sent-command))))) (ert-deftest ai-code-test-write-prompt-records-follow-up-suffix-in-prompt-file () "Test that discussion follow-up suffix is also recorded in the prompt file." @@ -295,8 +304,11 @@ (let ((contents (buffer-string))) (should (string-match-p "Explain this function" contents)) (should (string-match-p "BASE SUFFIX" contents)) - (should (string-match-p "2-3 numbered candidate next[[:space:]\n]+steps" - contents))))) + (should (string-match-p "3-4 numbered candidate next[[:space:]\n]+steps" + contents)) + (should (string-match-p + "At least 2 candidates must[[:space:]\n]+be AI-actionable items" + contents))))) (delete-directory temp-dir t)))) (ert-deftest ai-code-test-write-prompt-appends-follow-up-suffix-for-send-command-non-code-change () @@ -317,8 +329,23 @@ ((symbol-function 'ai-code-cli-switch-to-buffer) (lambda (&rest _args) nil))) (ai-code--write-prompt-to-file-and-send "Summarize this design") - (should (string-match-p "2-3 numbered candidate next[[:space:]\n]+steps" - sent-command))))) + (should (string-match-p "3-4 numbered candidate next[[:space:]\n]+steps" + sent-command)) + (should (string-match-p + "either a code change or tool usage" + sent-command))))) + +(ert-deftest ai-code-test-next-step-suggestion-suffix-requires-actionable-items () + "Test that numbered next-step suggestions require actionable AI items." + (should (string-match-p + "3-4 numbered candidate next[[:space:]\n]+steps" + ai-code-next-step-suggestion-suffix)) + (should (string-match-p + "At least 2 candidates must[[:space:]\n]+be AI-actionable items" + ai-code-next-step-suggestion-suffix)) + (should (string-match-p + "either a code change or tool usage" + ai-code-next-step-suggestion-suffix))) (ert-deftest ai-code-test-auto-test-type-ask-choices-use-explicit-red-green-blue-labels () "Test that default ask choices use explicit staged TDD labels."