-
Notifications
You must be signed in to change notification settings - Fork 30
Expand file tree
/
Copy pathai-code-session.el
More file actions
336 lines (294 loc) · 13.1 KB
/
Copy pathai-code-session.el
File metadata and controls
336 lines (294 loc) · 13.1 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
;;; ai-code-session.el --- AI session registry and dashboard -*- lexical-binding: t; -*-
;; Author: Kang Tu <tninja@gmail.com>
;; SPDX-License-Identifier: Apache-2.0
;;; Commentary:
;; This library tracks active AI coding sessions and provides a small query/update
;; API plus a simple dashboard for returning to active work.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'tabulated-list)
(declare-function magit-get-current-branch "magit-git" ())
(declare-function magit-git-lines "magit-git" (&rest args))
(declare-function magit-status-setup-buffer "magit-status" (directory))
(cl-defstruct ai-code-session
id
buffer
backend
repo-root
task-file
created-at
updated-at
metadata)
(defvar ai-code-session--sessions (make-hash-table :test 'equal)
"Hash table mapping AI session ids to `ai-code-session' objects.")
(defvar ai-code-session--next-id 0
"Counter used to generate lightweight AI session ids.")
(defun ai-code-session--generate-id ()
"Return a new AI session id."
(format "S%s" (cl-incf ai-code-session--next-id)))
(defun ai-code-session--normalize-backend (backend)
"Return BACKEND normalized for storage."
(cond
((symbolp backend) (symbol-name backend))
((stringp backend) backend)
(t nil)))
(defun ai-code-session--normalize-directory (directory)
"Return DIRECTORY normalized as an absolute directory path."
(when (and (stringp directory)
(not (string-empty-p directory)))
(file-name-as-directory (expand-file-name directory))))
(defun ai-code-session--normalize-file (file)
"Return FILE normalized as an absolute file path."
(when (and (stringp file)
(not (string-empty-p file)))
(expand-file-name file)))
(defun ai-code-session--find-by-buffer (buffer)
"Return the session object associated with BUFFER, or nil."
(when (buffer-live-p buffer)
(cl-find-if (lambda (session)
(eq (ai-code-session-buffer session) buffer))
(hash-table-values ai-code-session--sessions))))
(defun ai-code-session--merge-plists (base plist)
"Return BASE with the plist values from PLIST applied."
(let ((result (copy-sequence (or base '()))))
(while plist
(setq result (plist-put result (pop plist) (pop plist))))
result))
(cl-defun ai-code-session-register (&key buffer backend repo-root task-file metadata id)
"Register or update an AI session and return it.
BUFFER is the session buffer. BACKEND, REPO-ROOT, TASK-FILE, and METADATA
describe the session. ID is optional and mainly useful when restoring state."
(unless (buffer-live-p buffer)
(user-error "Cannot register session without a live buffer"))
(let* ((now (current-time))
(session (or (and id (gethash id ai-code-session--sessions))
(ai-code-session--find-by-buffer buffer))))
(if session
(progn
(setf (ai-code-session-buffer session) buffer
(ai-code-session-updated-at session) now)
(when backend
(setf (ai-code-session-backend session)
(ai-code-session--normalize-backend backend)))
(when repo-root
(setf (ai-code-session-repo-root session)
(ai-code-session--normalize-directory repo-root)))
(when task-file
(setf (ai-code-session-task-file session)
(ai-code-session--normalize-file task-file)))
(when metadata
(setf (ai-code-session-metadata session)
(ai-code-session--merge-plists
(ai-code-session-metadata session)
metadata))))
(setq session
(make-ai-code-session
:id (or id (ai-code-session--generate-id))
:buffer buffer
:backend (ai-code-session--normalize-backend backend)
:repo-root (ai-code-session--normalize-directory repo-root)
:task-file (ai-code-session--normalize-file task-file)
:created-at now
:updated-at now
:metadata (copy-sequence metadata)))
(puthash (ai-code-session-id session) session ai-code-session--sessions))
session))
(defun ai-code-session-unregister (id-or-buffer)
"Remove the session identified by ID-OR-BUFFER from the registry."
(when-let ((session (ai-code-session-get id-or-buffer)))
(remhash (ai-code-session-id session) ai-code-session--sessions)))
(defun ai-code-session-get (id-or-buffer)
"Return the registered session identified by ID-OR-BUFFER."
(cond
((bufferp id-or-buffer)
(ai-code-session--find-by-buffer id-or-buffer))
((and (stringp id-or-buffer)
(not (string-empty-p id-or-buffer)))
(gethash id-or-buffer ai-code-session--sessions))
(t nil)))
(defun ai-code-session-list ()
"Return the current registered AI sessions ordered by recent activity."
(sort (copy-sequence (hash-table-values ai-code-session--sessions))
(lambda (left right)
(time-less-p (ai-code-session-updated-at right)
(ai-code-session-updated-at left)))))
(defun ai-code-session-update-metadata (id-or-buffer metadata)
"Merge METADATA into the session identified by ID-OR-BUFFER."
(when-let ((session (ai-code-session-get id-or-buffer)))
(setf (ai-code-session-metadata session)
(ai-code-session--merge-plists
(ai-code-session-metadata session)
metadata)
(ai-code-session-updated-at session) (current-time))
session))
(defun ai-code-session--status (session)
"Return a simple status string for SESSION."
(let ((buffer (ai-code-session-buffer session)))
(if (and (buffer-live-p buffer)
(when-let ((process (get-buffer-process buffer)))
(process-live-p process)))
"running"
"stopped")))
(defun ai-code-session--branch (repo-root)
"Return the current branch for REPO-ROOT, or nil."
(when (and repo-root (file-directory-p repo-root))
(let ((default-directory repo-root))
(ignore-errors
(magit-get-current-branch)))))
(defun ai-code-session--dirty-count (repo-root)
"Return the dirty file count for REPO-ROOT, or nil."
(when (and repo-root (file-directory-p repo-root))
(let ((default-directory repo-root))
(ignore-errors
(length
(magit-git-lines "status" "--porcelain" "--untracked-files=normal"))))))
(defun ai-code-session--default-metadata (session)
"Return refreshed metadata plist for SESSION."
(let* ((repo-root (ai-code-session-repo-root session))
(metadata (ai-code-session-metadata session))
(branch (ai-code-session--branch repo-root))
(dirty-count (ai-code-session--dirty-count repo-root)))
(list :branch (or branch (plist-get metadata :branch))
:status (ai-code-session--status session)
:dirty-count (or dirty-count (plist-get metadata :dirty-count) 0))))
(defun ai-code-session-refresh ()
"Refresh session state and return the live session list."
(dolist (session (copy-sequence (hash-table-values ai-code-session--sessions)))
(let ((buffer (ai-code-session-buffer session)))
(if (not (buffer-live-p buffer))
(ai-code-session-unregister (ai-code-session-id session))
(ai-code-session-update-metadata
(ai-code-session-id session)
(ai-code-session--default-metadata session)))))
(ai-code-session-list))
(defconst ai-code-session-dashboard-buffer-name "*AI Code Sessions*"
"Buffer name used by the AI session dashboard.")
(defconst ai-code-session-dashboard-shortcuts-hint
"Keys: RET visit session r/g refresh k kill session D magit status"
"Shortcut hint shown in the dashboard mode line.")
(defun ai-code-session-dashboard--insert-footer ()
"Insert dashboard help below the session list."
(let ((inhibit-read-only t))
(goto-char (point-max))
(unless (bolp)
(insert "\n"))
(insert (propertize ai-code-session-dashboard-shortcuts-hint
'face 'mode-line-inactive))
(insert "\n")))
(defun ai-code-session-dashboard--repo-name (session)
"Return a short repository name for SESSION."
(when-let ((repo-root (ai-code-session-repo-root session)))
(file-name-nondirectory (directory-file-name repo-root))))
(defun ai-code-session-dashboard--task-name (session)
"Return a display task file name for SESSION."
(when-let ((task-file (ai-code-session-task-file session)))
(file-name-nondirectory task-file)))
(defun ai-code-session-dashboard--backend-label (backend)
"Return a human-friendly label for BACKEND."
(let ((text (cond
((symbolp backend) (symbol-name backend))
((stringp backend) backend)
(t ""))))
(capitalize (replace-regexp-in-string "[-_]+" " " text))))
(defun ai-code-session-dashboard--entry (session)
"Return the `tabulated-list-mode' entry for SESSION."
(let* ((metadata (ai-code-session-metadata session))
(branch (or (plist-get metadata :branch) ""))
(status (or (plist-get metadata :status) ""))
(dirty-count (number-to-string (or (plist-get metadata :dirty-count) 0))))
(list (ai-code-session-id session)
(vector
(ai-code-session-id session)
(or (ai-code-session-dashboard--repo-name session) "")
(or (ai-code-session-dashboard--task-name session) "")
(ai-code-session-dashboard--backend-label
(ai-code-session-backend session))
branch
status
dirty-count))))
(defun ai-code-session-dashboard--entries ()
"Return dashboard entries for all active sessions."
(mapcar #'ai-code-session-dashboard--entry
(ai-code-session-refresh)))
(defun ai-code-session-dashboard--session-at-point ()
"Return the dashboard session at point."
(ai-code-session-get (tabulated-list-get-id)))
(defun ai-code-session-dashboard-refresh ()
"Refresh the AI session dashboard."
(interactive)
(setq tabulated-list-entries (ai-code-session-dashboard--entries))
(tabulated-list-print t)
(save-excursion
(ai-code-session-dashboard--insert-footer)))
(defun ai-code-session-dashboard-visit ()
"Visit the session buffer on the current line."
(interactive)
(if-let* ((session (ai-code-session-dashboard--session-at-point))
(buffer (ai-code-session-buffer session))
((buffer-live-p buffer)))
(pop-to-buffer buffer)
(user-error "No live AI session on this line")))
(defun ai-code-session-dashboard-kill-session ()
"Kill the session on the current line, after confirmation when needed."
(interactive)
(let* ((session (or (ai-code-session-dashboard--session-at-point)
(user-error "No AI session on this line")))
(buffer (ai-code-session-buffer session))
(process (and (buffer-live-p buffer) (get-buffer-process buffer))))
(when (and process
(process-live-p process)
(not (y-or-n-p (format "Kill AI session %s? "
(ai-code-session-id session)))))
(user-error "Session kill canceled"))
(when (and process (process-live-p process))
(delete-process process))
(ai-code-session-unregister (ai-code-session-id session))
(when (buffer-live-p buffer)
(kill-buffer buffer))
(ai-code-session-dashboard-refresh)))
(defun ai-code-session-dashboard-open-diff ()
"Open Magit status for the repository on the current line."
(interactive)
(if-let* ((session (ai-code-session-dashboard--session-at-point))
(repo-root (ai-code-session-repo-root session)))
(magit-status-setup-buffer repo-root)
(user-error "No repository is associated with this session")))
(defvar ai-code-session-dashboard-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map tabulated-list-mode-map)
(define-key map (kbd "RET") #'ai-code-session-dashboard-visit)
(define-key map (kbd "r") #'ai-code-session-dashboard-refresh)
(define-key map (kbd "g") #'ai-code-session-dashboard-refresh)
(define-key map (kbd "k") #'ai-code-session-dashboard-kill-session)
(define-key map (kbd "D") #'ai-code-session-dashboard-open-diff)
map)
"Keymap used by `ai-code-session-dashboard-mode'.")
(define-derived-mode ai-code-session-dashboard-mode tabulated-list-mode "AI Sessions"
"Major mode for the AI session dashboard."
(setq tabulated-list-format
[("Session" 10 t)
("Repo" 18 t)
("Task file" 24 t)
("Backend" 14 t)
("Branch" 20 t)
("Status" 12 t)
("Dirty files" 11 t)])
(setq tabulated-list-padding 2)
;; Keep shortcut hints visible at the bottom of the dashboard window.
(setq-local mode-line-format
(list " " (propertize ai-code-session-dashboard-shortcuts-hint
'face 'mode-line)))
(add-hook 'tabulated-list-revert-hook #'ai-code-session-dashboard-refresh nil t)
(tabulated-list-init-header))
;;;###autoload
(defun ai-code-session-dashboard ()
"Show the AI session dashboard."
(interactive)
(let ((buffer (get-buffer-create ai-code-session-dashboard-buffer-name)))
(with-current-buffer buffer
(ai-code-session-dashboard-mode)
(ai-code-session-dashboard-refresh))
(pop-to-buffer buffer)))
(provide 'ai-code-session)
;;; ai-code-session.el ends here