diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c7882..78d0d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `eca-scratch-chat`: start or switch to a chat that belongs to no project, for questions unrelated to the code being visited. The session runs in the directory named by `eca-scratch-directory`: by default an `eca-scratch` folder inside the system temp directory, created on demand, but when the option is set every scratch chat uses that directory instead. Either way no project context reaches the model, and repeated calls return to the same session like `scratch-buffer` does. - Bugfix: under evil (e.g. Doom), RET in normal state in chat buffers ran `markdown-do` from evil-collection's `markdown-mode-map` bindings, which `eca-chat-mode-map` inherits. It followed links but inserted a GFM checkbox (`* [ ]`) anywhere else, e.g. next to a question option. RET in normal state now goes through `eca-chat--key-pressed-return`. - Bugfix: a tool call awaiting approval whose expanded body is taller than the window no longer gets its label and Accept/Reject buttons scrolled above the window (#308). The window is anchored on the tool call with point on its Accept button (RET accepts), and once it resolves the view moves on to the next pending approval or back to the prompt. - Add `eca-chat-tool-call-functions`, an abnormal hook run with the session and content when a tool call changes state, e.g. to refresh magit after file edits. diff --git a/README.md b/README.md index f6ac3f1..95e1cce 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ M-x package-install eca Server / process - `eca`: Starts eca server/session + open chat +- `eca-scratch-chat`: Starts/opens a scratch session in + `eca-scratch-directory`, for questions unrelated to any project - `eca-stop`: Stop eca server/session - `eca-restart`: Restart eca server/session - `eca-workspaces`: live dashboard with all workspaces and chats, @@ -138,6 +140,7 @@ Inline prompt (chat from any buffer) Server / process - `eca-custom-command`: The `eca` server command; when nil ECA auto-downloads or uses `eca` from `$PATH`. +- `eca-scratch-directory`: Workspace used by `eca-scratch-chat`, by default an `eca-scratch` folder inside the system temp directory. - `eca-server-download-method`: Method to download server (`curl` or `url-retrieve`, Emacs built-in way). - `eca-server-download-url`: Custom URL to download the ECA server archive. - `eca-server-fetch-timeout`: Seconds before a GitHub fetch (release check/download connection) is considered stuck. diff --git a/eca.el b/eca.el index 0bbb7f2..ca27bb0 100644 --- a/eca.el +++ b/eca.el @@ -93,6 +93,18 @@ Tries git info first, then package.el version, then file modification date." :type 'hook :group 'eca) +(defcustom eca-scratch-directory + (expand-file-name "eca-scratch" temporary-file-directory) + "Directory used as the workspace of `eca-scratch-chat'. +Every scratch chat runs here, whatever this is set to. The default is +a dedicated folder inside the variable `temporary-file-directory' +rather than that directory itself, so scratch chats do not pick up the +files other programs leave there. Created on demand; ECA never cleans +it up, though with the default most systems empty the temp directory +on reboot." + :type 'directory + :group 'eca) + (defcustom eca-send-process-id t "Whether to send the Emacs process ID to the ECA server. When non-nil, the server uses it to detect when Emacs exits and @@ -426,6 +438,26 @@ When ARG is current prefix, ask for workspace roots to use." ('started (eca-chat-open session)) ('starting (eca-info "eca server is already starting"))))) +;;;###autoload +(defun eca-scratch-chat () + "Start or switch to a scratch eca session, unrelated to any project. +Like `scratch-buffer', this is where a quick question goes when it +has nothing to do with the code you happen to be visiting: the +session runs in the directory named by `eca-scratch-directory', so +no project context reaches the model. That directory is created +when missing." + (interactive) + (let ((root (directory-file-name (expand-file-name eca-scratch-directory)))) + (unless (file-directory-p root) + (make-directory root t)) + ;; Run from a scratch buffer: `eca' resolves the session from the + ;; current buffer, whose cached session belongs to the project being + ;; visited -- precisely what this chat must not reuse. A throwaway + ;; buffer starts with no cache, and the one `eca' sets dies with it. + (with-temp-buffer + (let ((eca-find-root-for-buffer-function (lambda () root))) + (eca))))) + (defun eca-stop-session (session) "Stop SESSION if running." (when (eca-process-running-p session) diff --git a/test/eca-test.el b/test/eca-test.el index 0a9e321..5aec172 100644 --- a/test/eca-test.el +++ b/test/eca-test.el @@ -28,5 +28,74 @@ (eca--handle-message session request) (expect 'eca-api-send-request-response :not :to-have-been-called)))) +(describe "eca-scratch-chat" + :var (scratch-dir) + + (before-each + ;; Under `temporary-file-directory' rather than a literal /tmp: + ;; these tests also run on Windows CI. + (setq scratch-dir (expand-file-name (make-temp-name "eca-scratch-test-") + temporary-file-directory)) + (setq eca-scratch-directory scratch-dir) + (setq eca--sessions '()) + (spy-on 'eca-process-start) + (spy-on 'eca-chat-open)) + + (after-each + (setq eca--sessions '()) + (when (file-directory-p scratch-dir) + (delete-directory scratch-dir t))) + + (it "creates the scratch directory when it does not exist" + (expect (file-directory-p scratch-dir) :to-be nil) + (eca-scratch-chat) + (expect (file-directory-p scratch-dir) :to-be-truthy)) + + (it "starts a session whose workspace is the scratch directory" + (eca-scratch-chat) + (expect (length (eca-vals eca--sessions)) :to-equal 1) + (expect (eca--session-workspace-folders (car (eca-vals eca--sessions))) + :to-equal (list (directory-file-name scratch-dir))) + (expect 'eca-process-start :to-have-been-called)) + + (it "reuses the scratch session instead of starting a second one" + (eca-scratch-chat) + (let ((session (car (eca-vals eca--sessions)))) + (setf (eca--session-status session) 'started) + (eca-scratch-chat) + (expect (length (eca-vals eca--sessions)) :to-equal 1) + (expect 'eca-chat-open :to-have-been-called-with session))) + + ;; The point of the command: the chat must not land in the session of + ;; whatever project the current buffer belongs to. + (it "does not reuse the session of the current buffer's project" + (let ((project (eca-create-session (list "/some/project")))) + (setf (eca--session-status project) 'started) + (setq-local eca--session-id-cache (eca--session-id project)) + (eca-scratch-chat) + (expect (length (eca-vals eca--sessions)) :to-equal 2) + (expect 'eca-chat-open :not :to-have-been-called-with project))) + + ;; The command must not disturb the buffer it was called from. + (it "leaves the current buffer's session cache untouched" + (let ((project (eca-create-session (list "/some/project")))) + (setf (eca--session-status project) 'started) + (setq-local eca--session-id-cache (eca--session-id project)) + (eca-scratch-chat) + (expect eca--session-id-cache :to-equal (eca--session-id project)))) + + (it "honours a custom eca-scratch-directory" + (let ((custom (expand-file-name (make-temp-name "eca-scratch-custom-") + temporary-file-directory))) + (unwind-protect + (progn + (setq eca-scratch-directory custom) + (eca-scratch-chat) + (expect (eca--session-workspace-folders + (car (eca-vals eca--sessions))) + :to-equal (list (directory-file-name custom)))) + (when (file-directory-p custom) + (delete-directory custom t)))))) + (provide 'eca-test) ;;; eca-test.el ends here