-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathai-code-file.el
More file actions
713 lines (672 loc) · 34 KB
/
ai-code-file.el
File metadata and controls
713 lines (672 loc) · 34 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
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
;;; ai-code-file.el --- File operations for AI code interface -*- lexical-binding: t; -*-
;; Author: Kang Tu <tninja@gmail.com>
;; SPDX-License-Identifier: Apache-2.0
;;; Commentary:
;; This file provides file operation functionality for the AI Code Interface package.
;;; Code:
(require 'magit)
(require 'dired)
(require 'comint)
(require 'subr-x)
(require 'ai-code-input)
(require 'ai-code-prompt-mode)
(declare-function ai-code-read-string "ai-code-input")
(declare-function ai-code--get-context-files-string "ai-code-input")
(declare-function ai-code--insert-prompt "ai-code-prompt-mode" (prompt-text))
(declare-function ai-code--process-word-for-filepath "ai-code-prompt-mode" (word git-root-truename))
(declare-function ai-code-call-gptel-sync "ai-code-prompt-mode" (prompt))
(declare-function ai-code--extract-radar-id "ai-code-prompt-mode" (text))
(declare-function ai-code--normalize-radar-text "ai-code-prompt-mode" (text))
(declare-function ai-code--resolve-auto-test-suffix-for-send "ai-code")
(declare-function ai-code-backends-infra--session-buffer-p "ai-code-backends-infra" (buffer))
(declare-function projectile-project-root "projectile")
(declare-function ai-code-run-test "ai-code-agile")
(defun ai-code--git-root (&optional dir)
"Return the normalized Git repository root path, or nil.
Calls `magit-toplevel' with optional DIR argument and applies
`file-truename' to resolve symlinks. Returns nil when not inside
a Git repository or when `magit-toplevel' signals an error."
(condition-case nil
(let ((root (magit-toplevel dir)))
(when root (file-truename root)))
(error nil)))
(defcustom ai-code-sed-command "sed"
"GNU sed command used to apply prompts to files."
:type 'string
:group 'ai-code)
;; Variables that will be defined in ai-code.el
(defvar ai-code-use-prompt-suffix)
(defvar ai-code-prompt-suffix)
(defvar ai-code-auto-test-type)
(defvar ai-code-auto-test-suffix)
(defvar ai-code-cli)
(defvar ai-code-selected-backend)
(defvar ai-code-task-use-gptel-filename)
(defun ai-code--resolve-auto-test-suffix-for-current-send ()
"Return auto test suffix for this send action in file operations."
(if (fboundp 'ai-code--resolve-auto-test-suffix-for-send)
(ai-code--resolve-auto-test-suffix-for-send)
ai-code-auto-test-suffix))
;;;###autoload
(defun ai-code-copy-buffer-file-name-to-clipboard (&optional arg)
"Copy the current buffer's file path or selected text to clipboard.
If in a magit status buffer, copy the current branch name.
If in a Dired buffer, copy the file at point or directory path.
If in a regular file buffer with selected text, copy text with file path.
Otherwise, copy the file path of the current buffer.
With prefix argument ARG \[universal-argument], always return full path
instead of processed path. File paths are processed to relative paths
with @ prefix if within git repo."
(interactive "P")
(let ((path-to-copy
(cond
;; If current buffer is a magit status buffer
((derived-mode-p 'magit-status-mode)
(magit-get-current-branch))
;; If current buffer is a file, use existing logic
((buffer-file-name)
(let ((git-root-truename (ai-code--git-root)))
(if (use-region-p)
(let ((processed-file (if (and git-root-truename (not arg))
(ai-code--process-word-for-filepath (buffer-file-name) git-root-truename)
(buffer-file-name))))
(format "%s in %s"
(buffer-substring-no-properties (region-beginning) (region-end))
processed-file))
(if (and git-root-truename (not arg))
(ai-code--process-word-for-filepath (buffer-file-name) git-root-truename)
(buffer-file-name)))))
;; If current buffer is a dired buffer
((derived-mode-p 'dired-mode)
(let* ((file-at-point (ignore-errors (dired-get-file-for-visit)))
(git-root-truename (ai-code--git-root)))
(if file-at-point
;; If there's a file under cursor, copy its processed path
(if (and git-root-truename (not arg))
(ai-code--process-word-for-filepath file-at-point git-root-truename)
file-at-point)
;; If no file under cursor, copy the dired directory path
(let ((dir-path (dired-current-directory)))
(if (and git-root-truename (not arg))
(ai-code--process-word-for-filepath dir-path git-root-truename)
dir-path)))))
;; For other buffer types, return nil
(t nil))))
(if path-to-copy
(progn
(kill-new path-to-copy)
(message "copied %s to clipboard" path-to-copy))
(message "No file path available to copy"))))
;;;###autoload
(defun ai-code-open-clipboard-file-path-as-dired ()
"Open the file or directory path from clipboard in Dired.
If the clipboard contains a valid file path, open its directory in Dired
in another window and move the cursor to that file. If the clipboard
contains a directory path, open it directly in Dired in another window."
(interactive)
(let ((path (current-kill 0)))
(if (and path (file-exists-p path))
(if (file-directory-p path)
(dired-other-window path)
(let* ((dir (file-name-directory path))
(file (file-name-nondirectory path))
(dired-buffer (dired-other-window dir)))
(with-current-buffer dired-buffer
(goto-char (point-min))
(when (search-forward (regexp-quote file) nil t)
(goto-char (match-beginning 0))))))
(message "Clipboard does not contain a valid file or directory path"))))
(defvar ai-code-run-file-history nil
"History list for `ai-code-run-current-file' commands.")
(defvar ai-code-shell-command-history nil
"History list for shell command prompts.")
(defun ai-code--run-command-in-comint (command run-directory display-name)
"Run COMMAND in a comint buffer using RUN-DIRECTORY and DISPLAY-NAME.
COMMAND is parsed into a program and its SWITCHES using
`split-string-and-unquote'. RUN-DIRECTORY is used as the default
directory for the process. DISPLAY-NAME is the buffer name applied to
the comint session. This function is a programmatic wrapper around
`comint-run'."
(let* ((command-parts (split-string-and-unquote command))
(program (car command-parts))
(switches (cdr command-parts)))
(unless program
(user-error "Command cannot be empty"))
(let* ((origin-window (selected-window))
(origin-buffer (window-buffer origin-window))
(effective-directory (or run-directory default-directory))
(comint-buffer-name (format "*%s*" (file-name-nondirectory program))))
(when-let* ((existing (get-buffer display-name)))
(unless (yes-or-no-p (format "Kill existing session %s? " display-name))
(user-error "Aborted running command: %s" command))
(when-let* ((proc (get-buffer-process existing)))
(delete-process proc))
(kill-buffer existing))
(let ((default-directory effective-directory))
(apply #'make-comint "Run" program nil switches))
(when (window-live-p origin-window)
(set-window-buffer origin-window origin-buffer))
(when-let* ((buffer (get-buffer comint-buffer-name)))
(with-current-buffer buffer
(rename-buffer display-name t)
(setq-local default-directory effective-directory))
(let ((session-window
(display-buffer buffer '((display-buffer-pop-up-window)
(inhibit-same-window . t)))))
(when (window-live-p session-window)
(select-window session-window))))
(get-buffer display-name))))
;;;###autoload
(defun ai-code-run-current-file ()
"Generate command to run current script file (.py, .js, .ts, or .sh).
Let user modify the command before running it in a comint buffer.
Maintains a dedicated history list for this command."
(interactive)
(let* ((current-file (buffer-file-name))
(file-ext (when current-file (file-name-extension current-file)))
(file-name (when current-file (file-name-nondirectory current-file)))
(last-command (when ai-code-run-file-history (car ai-code-run-file-history)))
(default-command
(cond
;; Check if current file is in the last run command
((and last-command file-name (string-match-p (regexp-quote file-name) last-command))
last-command)
;; Generate default command based on file extension
((string= file-ext "py")
(format "python %s" file-name))
((string= file-ext "js")
(format "node %s" file-name))
((string= file-ext "ts")
(format "npx tsx %s" file-name))
((string= file-ext "sh")
(format "bash %s" file-name))
(t nil))))
(unless current-file
(user-error "Current buffer is not visiting a file"))
(unless default-command
(user-error "Current file is not a .py, .js, .ts, or .sh file"))
(let ((command (read-string (format "Run command for %s: " file-name)
default-command
'ai-code-run-file-history)))
(let* ((run-directory (file-name-directory current-file))
(display-name (format "*ai-code-run-current-file: %s*"
(file-name-base file-name))))
(ai-code--run-command-in-comint command run-directory display-name)))))
;;;###autoload
(defun ai-code-apply-prompt-on-current-file ()
"Apply a user prompt to the current file and send to an AI CLI tool.
The file can be the one in the current buffer or at point in a Dired
buffer. It constructs a shell command:
sed \"1i <prompt>: \" <file> | <ai-code-cli>
and runs it in a compilation buffer."
(interactive)
(when (memq (and (boundp 'ai-code-selected-backend)
ai-code-selected-backend)
'(agent-shell eca))
(user-error "Backend '%s' does not support piping file content via ai-code-cli"
ai-code-selected-backend))
(let* ((prompt (ai-code-read-string "Prompt: "))
(suffix-parts (delq nil (list ai-code-prompt-suffix
(when ai-code-auto-test-type
(ai-code--resolve-auto-test-suffix-for-current-send)))))
(suffix (when (and ai-code-use-prompt-suffix suffix-parts)
(string-join suffix-parts ", ")))
(prompt-with-suffix (if suffix
(concat prompt ", " suffix)
prompt))
(file-name (cond
((derived-mode-p 'dired-mode)
(dired-get-filename))
((buffer-file-name)
(buffer-file-name))
(t (user-error "Cannot determine the file name"))))
(command (format "%s \"1i %s: \" %s | %s"
ai-code-sed-command
(shell-quote-argument prompt-with-suffix)
(shell-quote-argument file-name)
ai-code-cli)))
(when file-name
(let* ((default-directory (file-name-directory file-name))
(buffer-name (format "*ai-code-apply-prompt: %s*"
(file-name-base file-name))))
(compilation-start
command
nil
(lambda (_mode)
(generate-new-buffer-name buffer-name)))))))
(defun ai-code--generate-shell-command (&optional initial-input)
"Generate shell command from user input or AI assistance.
Read initial command from user with INITIAL-INPUT as default.
If command starts with ':', treat as prompt for AI to generate command.
Return the final command string."
(let* ((initial-command (read-string "Shell command: " initial-input
'ai-code-shell-command-history))
;; if current buffer is Dired buffer, replace the * character
;; inside initial-command with file base name under cursor,
;; or marked files, separate with space
(initial-command
(if (and (derived-mode-p 'dired-mode)
(string-match-p "\\*" initial-command))
(let* ((files (ignore-errors (dired-get-marked-files)))
(file-names (when files
(delete-dups (mapcar #'file-name-nondirectory files)))))
(if file-names
(replace-regexp-in-string
(regexp-quote "*")
(mapconcat #'identity file-names " ")
initial-command
nil
t)
initial-command))
initial-command))
(command
(if (string-prefix-p ":" initial-command)
;; If command starts with :, treat as prompt for AI
(let* ((base-prompt (concat "Generate a shell command (pure command, no fense, no duplicate, in one line) for: " (substring initial-command 1)))
(prompt (if (derived-mode-p 'dired-mode)
(let* ((files (ignore-errors (dired-get-marked-files)))
(file-names (when files (delete-dups (mapcar #'file-name-nondirectory files)))))
(if file-names
(concat (format "For these files %s, " (mapconcat #'identity file-names " "))
base-prompt)
base-prompt))
base-prompt)))
(condition-case err
;; DONE: if ai-generated has more than one line,
;; only take the first line
(let* ((ai-generated (ai-code-call-gptel-sync prompt))
(first-line (when ai-generated
(car (split-string ai-generated "\n" t)))))
(when first-line
;; Ask user to confirm/edit the AI-generated command
(read-string "Shell command (AI generated): " (string-trim first-line))))
(error
(message "Failed to generate command with AI: %s" err)
"")))
;; Regular command, use as-is
initial-command)))
command))
;;;###autoload
(defun ai-code-shell-cmd (&optional initial-input)
"Run shell command in Dired directory or insert command in shell buffers.
If current buffer is a Dired buffer, get user input shell command with
`read-string', then run it under the directory of Dired buffer, in a
buffer with name as *ai-code-shell-cmd: <current-dir>*. If current
buffer is `shell-mode', `eshell-mode' or `sh-mode', get input and insert
command under cursor, do not run it. If the command starts with
\\=':\\=', it means it is a prompt. In this case, ask gptel to generate
the corresponding shell command, and call `ai-code-shell-cmd' with that
command as candidate. INITIAL-INPUT is the initial text to populate the
shell command prompt."
(interactive)
(cond
;; Handle shell modes: insert command without running
((memq major-mode '(shell-mode eshell-mode))
(let ((command (ai-code--generate-shell-command initial-input)))
(when (and command (not (string= command "")))
(insert command))))
;; Handle other modes: run command in compilation buffer
(t
(let* ((current-dir (cond
((derived-mode-p 'dired-mode)
(dired-current-directory))
(initial-input
default-directory)
(t nil))))
(unless current-dir
(user-error "Cannot determine working directory: requires either a Dired buffer or initial input"))
(let* ((command (ai-code--generate-shell-command initial-input))
(buffer-name (format "*ai-code-shell-cmd: %s*" (directory-file-name current-dir))))
(when (and command (not (string= command "")))
(let ((default-directory current-dir))
(compilation-start
command
nil
(lambda (_mode)
(generate-new-buffer-name buffer-name))))))))))
;;;###autoload
(defun ai-code-run-current-file-or-shell-cmd ()
"Run current file or shell command based on buffer state.
Call `ai-code-shell-cmd` when in Dired mode, shell modes or a region
is active; otherwise run the current file."
(interactive)
(cond
((derived-mode-p 'dired-mode)
(ai-code-shell-cmd))
((memq major-mode '(shell-mode eshell-mode))
(ai-code-shell-cmd))
((use-region-p)
(let ((initial-input (string-trim (buffer-substring-no-properties (region-beginning)
(region-end)))))
(ai-code-shell-cmd initial-input)))
(t
(ai-code-run-current-file))))
;;;###autoload
(defun ai-code-build-or-test-project ()
"Build or test the current project based on user choice.
If user chooses to build, check for build.sh in the project root
and send to AI for execution. Otherwise, ask AI to generate a build command.
If user chooses to test the whole project, call `ai-code-test-project'.
If user chooses scoped testing, call `ai-code-run-test'.
If user chooses linting, call `ai-code-lint-current-file'."
(interactive)
(let ((action (completing-read "Choose action: " '("Build project" "Test project" "Test on scope" "Lint current file") nil t)))
(cond
;; DONE add an option Lint current file. When selected, let AI to use the new added diagnosis emacs mcp tool to lint current file, and follow up with error analysis and code fix suggestions if linting errors are found.
((string= action "Build project") (ai-code-build-project))
((string= action "Test project") (ai-code-test-project))
((string= action "Test on scope") (ai-code-run-test))
((string= action "Lint current file") (ai-code-lint-current-file)))))
(defun ai-code--lint-current-file-follow-up ()
"Return follow-up instructions for lint diagnostics."
(concat "\n\nIf lint errors are found:"
"\n1. Provide error analysis for the diagnostics"
"\n2. Explain likely root causes in the current file"
"\n3. Suggest specific code fix suggestions for user approval before making any changes"))
;;;###autoload
(defun ai-code-lint-current-file ()
"Ask AI to lint the current file with Emacs diagnostics and explain issues."
(interactive)
(unless buffer-file-name
(user-error "Current buffer is not visiting a file"))
(let* ((repo-context (ai-code--format-repo-context-info))
(initial-input
(concat
"Use the get_diagnostics Emacs MCP tool to lint the current file and report the results."
(format "\nCurrent file: %s" buffer-file-name)
(when repo-context (concat "\n" repo-context))
(ai-code--lint-current-file-follow-up)))
(prompt (ai-code-read-string "Send to AI: " initial-input)))
(ai-code--insert-prompt prompt)))
(defun ai-code--project-root ()
"Return the current project root using Projectile first, then Git."
(or (and (fboundp 'projectile-project-root)
(ignore-errors (projectile-project-root)))
(ai-code--git-root)))
;;;###autoload
(defun ai-code-build-project ()
"Build the current project.
Check for build.sh in the project root and send to AI for execution.
Otherwise, ask AI to generate a build command."
(interactive)
(let* ((proj-root (ai-code--project-root))
(build-script (when proj-root (expand-file-name "build.sh" proj-root)))
(repo-context (ai-code--format-repo-context-info))
(error-handling-instruction
(concat "\n\nIf the build fails:"
"\n1. Analyze the error output carefully to identify the failure point"
"\n2. Investigate the root cause by examining relevant source files"
"\n3. Provide a clear explanation of what went wrong"
"\n4. Suggest specific code fixes for user approval before making any changes"))
(initial-input
(if (and proj-root build-script (file-exists-p build-script))
;; build.sh exists, ask AI to run it
(concat "Run the build script and report the results. "
(format "\nProject root: %s" proj-root)
(format "\nBuild script: %s" build-script)
(when repo-context (concat "\n" repo-context))
error-handling-instruction)
;; No build.sh, ask AI to generate build command
(concat "Build the current project. Provide the build command and execute it if possible. "
(when proj-root (format "\nProject root: %s" proj-root))
(when repo-context (concat "\n" repo-context))
error-handling-instruction)))
(prompt (ai-code-read-string "Send to AI: " initial-input)))
(ai-code--insert-prompt prompt)))
;;;###autoload
(defun ai-code-test-project ()
"Ask AI to run test on the whole project and provide failure analysis."
(interactive)
(let* ((proj-root (ai-code--project-root))
(repo-context (ai-code--format-repo-context-info))
(failure-follow-up
(concat "\n\nIf test fails:"
"\n1. Analyze the test results to identify failing tests and failure points"
"\n2. Investigate likely root causes by checking related source and test files"
"\n3. Explain what failed and why in clear terms"
"\n4. Provide specific code fix suggestions for user approval before making any changes"))
(initial-input
(concat "Run test on the whole project and report the results."
(when proj-root (format "\nProject root: %s" proj-root))
(when repo-context (concat "\n" repo-context))
failure-follow-up))
(prompt (ai-code-read-string "Send to AI: " initial-input)))
(ai-code--insert-prompt prompt)))
(defvar ai-code--repo-context-info (make-hash-table :test #'equal)
"Hash table storing context info lists per Git repository root.")
(defun ai-code--store-context-entry (repo-root context)
"Store CONTEXT string for REPO-ROOT in `ai-code--repo-context-info'."
(let ((current (gethash repo-root ai-code--repo-context-info)))
(unless (member context current)
(setq current (cons context current)))
(puthash repo-root current ai-code--repo-context-info)))
;;;###autoload
(defun ai-code-add-context ()
"Capture current buffer context and store it per Git repository.
When no region is selected, use the full file path and current function
\(if any). When a region is active, use the file path with line range
in the form filepath#Lstart-Lend."
(interactive)
(let* ((current-root (ai-code--git-root))
(all-roots (let ((roots '()))
(walk-windows
(lambda (w)
(with-current-buffer (window-buffer w)
(let ((root (ai-code--git-root)))
(when (and root (not (member root roots)))
(push root roots)))))
nil 'current-frame)
(when (fboundp 'ai-code-backends-infra--session-buffer-p)
(dolist (buf (buffer-list))
(when (ai-code-backends-infra--session-buffer-p buf)
(with-current-buffer buf
(let ((root (ai-code--git-root)))
(when (and root (not (member root roots)))
(push root roots)))))))
(nreverse roots)))
(ordered-roots (if (and current-root (member current-root all-roots))
(cons current-root (remove current-root all-roots))
all-roots))
(repo-root (cond
((null ordered-roots)
(user-error "Not inside a Git repository"))
((= (length ordered-roots) 1)
(car ordered-roots))
(t
(completing-read "Select Git repository for context: "
ordered-roots
nil t nil nil (car ordered-roots))))))
(if (derived-mode-p 'dired-mode)
(let* ((all-marked (dired-get-marked-files))
(file-at-point (dired-get-filename nil t))
(has-marks (and all-marked
(not (and (= (length all-marked) 1)
file-at-point
(equal (car all-marked) file-at-point)))))
(targets (cond
(has-marks all-marked)
(file-at-point (list file-at-point))
(t nil))))
(unless targets
(user-error "No file or directory selected in Dired"))
(dolist (path targets)
(ai-code--store-context-entry repo-root path))
(message "Added context for %s: %s"
repo-root
(mapconcat #'identity targets ", ")))
(unless buffer-file-name
(user-error "Current buffer is not visiting a file"))
(let ((context
(if (use-region-p)
(let* ((start (region-beginning))
(end (region-end))
(start-line (line-number-at-pos (min start end)))
(end-line (line-number-at-pos (max start end))))
(format "%s#L%d-L%d" buffer-file-name start-line end-line))
(let ((function-name (when (fboundp 'which-function)
(which-function))))
(if (and function-name
(stringp function-name)
(not (string-empty-p function-name)))
(format "%s#%s" buffer-file-name function-name)
buffer-file-name)))))
(ai-code--store-context-entry repo-root context)
(message "Added context for %s: %s" repo-root context)))))
(defun ai-code-list-context ()
"Display stored context entries grouped by Git repository."
(interactive)
(if (= (hash-table-count ai-code--repo-context-info) 0)
(message "No stored context info.")
(let ((sections '()))
(maphash
(lambda (repo contexts)
(let ((entries (if contexts
(mapconcat (lambda (ctx)
(concat " - " ctx))
(reverse contexts)
"\n")
" (no entries)")))
(push (format "Repo: %s\n%s" repo entries) sections)))
ai-code--repo-context-info)
(message "%s" (string-join (nreverse sections) "\n\n")))))
(defun ai-code-clear-context (&optional arg)
"Clear stored context entries.
Without ARG, clear entries for the current Git repository.
With prefix ARG, clear all repositories."
(interactive "P")
(if arg
(progn
(clrhash ai-code--repo-context-info)
(message "Cleared all stored context info."))
(let ((repo-root (or (ai-code--git-root)
(user-error "Not inside a Git repository"))))
(if (gethash repo-root ai-code--repo-context-info)
(progn
(remhash repo-root ai-code--repo-context-info)
(message "Cleared context info for %s." repo-root))
(message "No context info stored for %s." repo-root)))))
(defun ai-code-context-action (_arg)
"Add, show, or clear context entries via `completing-read'.
Presents a menu with three choices: Add context, Show context, or
Clear context. The prefix argument ARG is ignored."
(interactive "P")
(let ((action (completing-read "Context action: "
'("Add context" "Show context" "Clear context")
nil t)))
(pcase action
("Add context"
(call-interactively #'ai-code-add-context)
(ai-code-list-context))
("Show context"
(ai-code-list-context))
("Clear context"
(call-interactively #'ai-code-clear-context)))))
(defun ai-code--format-repo-context-info ()
"Return formatted repository context string or nil.
Includes stored context entries for the current Git repository if available."
(when (and (boundp 'ai-code--repo-context-info)
ai-code--repo-context-info)
(let ((repo-root (ai-code--git-root)))
(when repo-root
(let ((entries (gethash repo-root ai-code--repo-context-info)))
(when entries
(concat "\nStored repository context:\n"
(mapconcat (lambda (ctx)
(concat " - " ctx))
(reverse entries)
"\n"))))))))
;;;###autoload
(defun ai-code-toggle-current-buffer-dedicated (arg)
"Toggle the dedicated state of the current buffer's window.
When a window is dedicated, Emacs will not automatically reuse it for
displaying other buffers. With prefix ARG \[universal-argument],
toggle dedication for every window in the current frame."
;; DONE: if C-u pressed, toggle dedicate to all buffers inside current window
(interactive "P")
(let* ((targets (if arg
(window-list nil 'no-minibuf)
(list (selected-window))))
(results '()))
(dolist (window targets)
(let ((buffer (window-buffer window)))
(when (buffer-file-name buffer)
(let ((new-status (not (window-dedicated-p window))))
(set-window-dedicated-p window new-status)
(push (format "%s: %s" (buffer-name buffer) (if new-status "dedicated" "free")) results)))))
;; DONE: show each affected buffer name and dedicate status
(if results
(message "Window dedication updated: %s" (mapconcat #'identity (nreverse results) ", "))
(message "No file buffers found in target windows"))))
(defun ai-code--sanitize-generated-path-name (name)
"Sanitize generated NAME for file or directory creation."
(let ((clean-name (downcase (string-trim (ai-code--normalize-radar-text name)))))
(setq clean-name (or (car (split-string clean-name "\n" t)) ""))
(setq clean-name (replace-regexp-in-string "[^a-z0-9._-]" "_" clean-name))
(setq clean-name (replace-regexp-in-string "_+" "_" clean-name))
(setq clean-name (replace-regexp-in-string "^[_.]+\\|[_.]+$" "" clean-name))
clean-name))
(defun ai-code--generate-file-or-dir-name-with-gptel (description target-type)
"Generate a file or directory name for DESCRIPTION and TARGET-TYPE.
TARGET-TYPE should be either \"file\" or \"directory\"."
(let* ((prompt
(format
(concat "Generate one concise lowercase %s name for this request: %s\n"
"Rules: return only the name, no explanation, no markdown, no quotes, max 60 chars.\n"
"Use letters/numbers/_/./- only, no slashes or path separators.")
target-type
description))
(generated-name (condition-case nil
(ai-code-call-gptel-sync prompt)
(error description))))
(ai-code--sanitize-generated-path-name generated-name)))
;;;###autoload
(defun ai-code-create-file-or-dir ()
"Generate and create a new file or directory under current directory.
Use GPTel to generate the name, ask user to confirm, then create and open
it in another window."
(interactive)
(let* ((current-dir (if (derived-mode-p 'dired-mode)
(dired-current-directory)
default-directory))
(target-type (completing-read "Create target type: "
'("file" "directory")
nil t nil nil "file"))
(description (read-string (format "Describe the %s to create: " target-type)))
(radar-id (ai-code--extract-radar-id description)))
(when (string-empty-p (string-trim description))
(user-error "Description cannot be empty"))
(let* ((generated-name (if (and (boundp 'ai-code-task-use-gptel-filename)
ai-code-task-use-gptel-filename)
(ai-code--generate-file-or-dir-name-with-gptel description target-type)
(ai-code--sanitize-generated-path-name description)))
(generated-name (if radar-id
(concat (format "rdar_%s_" radar-id) generated-name)
generated-name))
(fallback-name (if (string-empty-p generated-name)
(if (string= target-type "directory")
"new_dir"
"new_file.txt")
generated-name))
(confirmed-name
(read-string (format "Confirm %s name (will create under %s): "
target-type
(abbreviate-file-name current-dir))
fallback-name))
(sanitized-name (ai-code--sanitize-generated-path-name confirmed-name)))
(when (string-empty-p sanitized-name)
(user-error "Confirmed name cannot be empty"))
(let ((target-path (expand-file-name sanitized-name current-dir)))
(if (string= target-type "directory")
(progn
(unless (file-directory-p target-path)
(make-directory target-path t))
(dired-other-window target-path)
(message "Opened directory: %s" target-path))
(progn
(unless (file-exists-p target-path)
(make-directory (file-name-directory target-path) t)
(write-region "" nil target-path nil 'silent))
(find-file-other-window target-path)
(message "Opened file: %s" target-path)))))))
(provide 'ai-code-file)
;;; ai-code-file.el ends here