-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy patheca-util.el
More file actions
378 lines (323 loc) · 14.4 KB
/
eca-util.el
File metadata and controls
378 lines (323 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
;;; eca-util.el --- ECA (Editor Code Assistant) util -*- lexical-binding: t; -*-
;; Copyright (C) 2025 Eric Dallo
;;
;; SPDX-License-Identifier: Apache-2.0
;;
;; This file is not part of GNU Emacs.
;;
;;; Commentary:
;;
;; The ECA (Editor Code Assistant) utils.
;;
;;; Code:
(require 'cl-lib)
(require 'dash)
(require 'f)
(require 'transient nil t)
(declare-function eca-api-notify "eca-api")
(defcustom eca-buttons-allow-mouse nil
"Whether to allow mouse clicks on ECA buttons."
:type 'boolean
:group 'eca)
(defcustom eca-find-root-for-buffer-function #'eca-find-root-for-buffer
"Function for getting the ECA's session root."
:type 'function
:group 'eca)
(defcustom eca-worktree-mode 'merged
"How ECA handles git worktrees of the same repository.
- `merged': all worktrees share one ECA session and chat history.
New worktrees are dynamically added to the running session when
a file inside them is visited. This is the default since 0.110.0
and is efficient when you want a unified AI context across branches.
- `isolated': each worktree gets its own independent ECA session,
chat history, and cache. Toggle-window, resume, and agent context
all scope to the individual worktree. This matches the behaviour
prior to 0.110.0 and is preferred for parallel worktree workflows."
:type '(choice (const :tag "Merged (shared session per repo)" merged)
(const :tag "Isolated (independent session per worktree)" isolated))
:group 'eca)
(defun eca-assoc (map key val)
"Return a new MAP with KEY associated to flat plist VAL, replacing any existing."
(cons (cons key val)
(cl-remove-if (lambda (pair) (equal (car pair) key)) map)))
(defun eca-dissoc (map key)
"Return a new MAP with KEY removed."
(cl-remove-if (lambda (pair) (equal (car pair) key)) map))
(defun eca-get (map key)
"Return the plist value associated with KEY in MAP, or nil."
(let ((pair (cl-find key map :key #'car :test #'equal)))
(when pair (cdr pair))))
(defun eca-vals (map)
"Return the plist values from MAP."
(-map #'cdr map))
(defun eca-plist-equal (plist1 plist2)
"Check if PLIST1 is equal to PLIST2."
(and (= (length plist1) (length plist2))
(cl-loop for (key val) on plist1 by #'cddr
always (equal val (plist-get plist2 key)))))
(defvar-local eca--session-id-cache nil)
(defvar eca--sessions '())
(defvar eca--session-ids 0)
(cl-defstruct eca--session
;; id to manage multiple eca sessions
(id nil)
;; The status of this session
(status 'stopped)
;; The eca <process>
(process nil)
;; the chat buffers
(chats '())
(last-chat-buffer nil)
;; A list of workspace folders of this session
(workspace-folders '())
;; A plist of request method names (strings) -> handlers used when
;; receiving requests from server.
(request-handlers '())
;; A plist of client request ids -> handlers for pending requests used when
;; receiving responses from server.
(response-handlers '())
;; The suported models by the server.
(models '())
;; The servers and their status.
(tool-servers '())
;; The supported chat agents by the server.
(chat-agents nil)
;; The available variants for the current model.
(chat-variants '())
;; The welcome message for new chats.
(chat-welcome-message "")
;; Init progress tasks alist: (taskId . plist) where plist has :title :type
(init-tasks nil)
;; Provider status list from providers/list response.
(providers nil)
;; Background jobs list from jobs/list and jobs/updated.
(jobs nil))
(defun eca-find-root-for-buffer ()
"Return the path that first matches the following:
- Buffer is within an existent eca session workspace-folder.
- Use `project` to return the project root if available.
- Otherwise return buffer file or `default-directory`."
(let* ((default-path (or (when buffer-file-name (file-name-directory buffer-file-name))
default-directory))
(existing-sessions-folders (-keep (lambda (session)
(--first (or (f-same? it default-path)
(f-ancestor-of? it default-path))
(eca--session-workspace-folders session)))
(eca-vals eca--sessions))))
(or (when existing-sessions-folders
(-max-by (lambda (a b)
(> (length (expand-file-name a))
(length (expand-file-name b))))
existing-sessions-folders))
(when (fboundp 'project-current)
(when-let* ((project (project-current)))
(if (fboundp 'project-root)
(project-root project)
(car (with-no-warnings
(project-roots project))))))
default-path)))
(defun eca--git-common-dir (dir)
"Return the absolute git common dir for DIR, or nil if not in a git repo.
Uses `git rev-parse --git-common-dir` to find the shared git
directory, which is the same for all worktrees of a repository."
(when-let* ((default-directory (expand-file-name dir))
(output (ignore-errors
(string-trim
(shell-command-to-string
"git rev-parse --git-common-dir 2>/dev/null")))))
(when (and (not (string-empty-p output))
;; Git < 2.5 echoes the flag back literally
(not (string= output "--git-common-dir")))
(expand-file-name output default-directory))))
(defun eca--session-for-worktree (root)
"Find a session whose workspace shares the same git repo as ROOT.
Returns the session if ROOT is a git worktree (or regular repo) that
shares the same `git-common-dir` as one of an existing session's
workspace folders. Returns nil otherwise."
(when-let* ((root-common-dir (eca--git-common-dir root)))
(-first (lambda (session)
(--first (when-let* ((folder-common-dir (eca--git-common-dir it)))
(string= (file-truename root-common-dir)
(file-truename folder-common-dir)))
(eca--session-workspace-folders session)))
(eca-vals eca--sessions))))
(defun eca--session-add-workspace-folder (session folder)
"Add FOLDER to SESSION's workspace-folders and notify the server."
(let ((folder (expand-file-name folder)))
(unless (--first (string= it folder)
(eca--session-workspace-folders session))
(setf (eca--session-workspace-folders session)
(append (eca--session-workspace-folders session) (list folder)))
(eca-api-notify
session
:method "workspace/didChangeWorkspaceFolders"
:params (list :event
(list :added (vector
(list :uri (eca--path-to-uri folder)
:name (file-name-nondirectory (directory-file-name folder))))
:removed [])))
(eca-info "Added workspace folder: %s" folder))))
(defun eca--session-remove-workspace-folder (session folder)
"Remove FOLDER from SESSION's workspace-folders and notify the server.
Refuses to remove the last remaining folder, since the Emacs client
resolves buffers to sessions by matching against workspace-folders and
an empty list would make the session unreachable. In `merged' worktree
mode, a removed folder whose git-common-dir still matches another
folder in the session can be auto-re-added by `eca-session' the next
time a buffer under it is visited."
(let* ((folder (expand-file-name folder))
(folders (eca--session-workspace-folders session)))
(cond
((not (--first (string= it folder) folders))
(eca-warn "Workspace folder not found: %s" folder))
((<= (length folders) 1)
(user-error "Cannot remove the last workspace folder"))
(t
(setf (eca--session-workspace-folders session)
(cl-remove-if (lambda (it) (string= it folder)) folders))
(eca-api-notify
session
:method "workspace/didChangeWorkspaceFolders"
:params (list :event
(list :added []
:removed (vector
(list :uri (eca--path-to-uri folder)
:name (file-name-nondirectory (directory-file-name folder)))))))
(eca-info "Removed workspace folder: %s" folder)))))
(defun eca-session ()
"Return the session related to root of current buffer otherwise nil."
(or (eca-get eca--sessions eca--session-id-cache)
(let* ((root (funcall eca-find-root-for-buffer-function))
(session (or (-first (lambda (session)
(--first (string= it root)
(eca--session-workspace-folders session)))
(eca-vals eca--sessions))
;; Worktree fallback: only in merged mode, find a session
;; sharing the same git repo and add this worktree to it.
;; In isolated mode each worktree gets its own session.
(when (eq eca-worktree-mode 'merged)
(when-let* ((worktree-session (eca--session-for-worktree root)))
(eca--session-add-workspace-folder worktree-session root)
worktree-session)))))
(when session
(setq-local eca--session-id-cache (eca--session-id session)))
session)))
(defun eca-create-session (workspace-roots)
"Create a new ECA session for WORKSPACE-ROOTS."
(let ((session (make-eca--session))
(id (cl-incf eca--session-ids)))
(setf (eca--session-id session) id)
(setf (eca--session-workspace-folders session) workspace-roots)
(setq eca--sessions (eca-assoc eca--sessions id session))
session))
(defun eca--session-project-name (session)
"Return the project name for SESSION.
Extracts the last directory component from the first
workspace folder. Falls back to \"unknown\"."
(if-let* ((roots (eca--session-workspace-folders session))
(root (car roots)))
(file-name-nondirectory (directory-file-name root))
"unknown"))
(defun eca-delete-session (session)
"Delete SESSION from existing sessions."
(when session
(setq eca--sessions
(eca-dissoc eca--sessions (eca--session-id session)))))
(defun eca-assert-session-running (session)
"Assert that a eca SESSION is running."
(unless session
(user-error "ECA must be running, no session found, start with `eca` command")))
(defvar eca--uri-file-prefix (pcase system-type
(`windows-nt "file:///")
(_ "file://"))
"Prefix for a file-uri.")
(defun eca--path-to-uri (path)
"Convert a PATH to a uri."
(concat eca--uri-file-prefix
(--> path
(expand-file-name it)
(or (file-remote-p it 'localname t) it))))
(defun eca--uri-to-path (uri)
"Convert a file URI to a file path."
(cond
((string-prefix-p "file:///" uri)
(url-unhex-string
(substring uri (if (eq system-type 'windows-nt) 8 7))))
((string-prefix-p "file://" uri)
(url-unhex-string (substring uri 6)))
(t uri)))
(defun eca-info (format &rest args)
"Display eca info message with FORMAT with ARGS."
(message "%s :: %s" (propertize "ECA" 'face 'success) (apply #'format format args)))
(defun eca-warn (format &rest args)
"Display eca warn message with FORMAT with ARGS."
(message "%s :: %s" (propertize "ECA" 'face 'warning) (apply #'format format args)))
(defun eca-error (format &rest args)
"Display eca error message with FORMAT with ARGS."
(message "%s :: %s" (propertize "ECA" 'face 'error) (apply #'format format args)))
(defun eca-buttonize (base-map text callback)
"Create a actionable TEXT that call CALLBACK when actioned.
Inheirits BASE-MAP."
(let ((km (make-composed-keymap (make-sparse-keymap) base-map))
(callback-int (lambda (&rest _)
(interactive)
(funcall callback))))
(when eca-buttons-allow-mouse
(define-key km (kbd "<mouse-1>") callback-int))
(define-key km (kbd "<return>") callback-int)
(define-key km (kbd "RET") callback-int)
(propertize text
'eca-button-on-action callback
'pointer 'hand
'help-echo text
'local-map km
'keymap km)))
(with-eval-after-load 'transient
(transient-define-prefix eca--transient-menu-prefix ()
"ECA transient menu."
[["Chat"
("n" "New" eca-chat-new)
("f" "Select" eca-chat-select)
("c" "Clear" eca-chat-clear)
("r" "Reset" eca-chat-reset)
("R" "Rename" eca-chat-rename)
("t" "Talk" eca-chat-talk)
("p" "Repeat prompt" eca-chat-repeat-prompt)
("C" "Clear prompt" eca-chat-clear-prompt)
("m" "Select model" eca-chat-select-model)
("v" "Select variant" eca-chat-select-variant)
("b" "Change agent" eca-chat-select-agent)
("o" "Open/close chat window" eca-chat-toggle-window)
("a" "Accept all pending tool calls" eca-chat-tool-call-accept-all)
("!" "Accept all pending tool calls and remember" eca-chat-tool-call-accept-all-and-remember)
("s" "Add to system prompt" eca-chat-add-context-to-system-prompt)
("u" "Add to user prompt" eca-chat-add-context-to-user-prompt)
("d" "Drop from system prompt" eca-chat-drop-context-from-system-prompt)
("A" "Accept next pending tool call" eca-chat-tool-call-accept-next)]
["Navigation"
("N h" "Message history" eca-chat-timeline)
("N c" "Chat" eca)
("N s" "Settings" eca-settings)
("N m" "MCP details" eca-mcp-details)
("N e" "Show stderr (logs)" eca-show-stderr)
("N E" "Show emacs errors" eca-show-errors)]
["Server"
("S r" "Restart" eca-restart)
("S s" "Stop" eca-stop)]
["Workspace"
("W a" "Add folder" eca-chat-add-workspace-root)
("W r" "Remove folder" eca-chat-remove-workspace-root)]]))
(defun eca-transient-menu ()
"Open the ECA transient menu.
Requires the `transient' package."
(interactive)
(unless (featurep 'transient)
(user-error "Install the `transient' package to use the ECA menu"))
(condition-case err
(transient-setup 'eca--transient-menu-prefix)
(error
(user-error
"ECA menu failed: %s. Try: M-x package-reinstall RET transient"
(error-message-string err)))))
(provide 'eca-util)
;;; eca-util.el ends here