-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathai-code-backends-infra.el
More file actions
1047 lines (940 loc) · 50.4 KB
/
ai-code-backends-infra.el
File metadata and controls
1047 lines (940 loc) · 50.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
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
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; ai-code-backends-infra.el --- Infrastructure for AI Code Terminals -*- lexical-binding: t; -*-
;; Author: Yoav Orot, Kang Tu, AI Agent, Steve Molitor
;; SPDX-License-Identifier: Apache-2.0
;; Keywords: ai, terminal, vterm, eat
;;; Commentary:
;; This library provides common infrastructure for AI-powered terminal interfaces,
;; including terminal backend abstraction (vterm/eat), window management,
;; and performance optimizations like anti-flicker and reflow glitch prevention.
;; Code was generated by AI Agent, using
;; https://github.com/manzaltu/claude-code-ide.el as reference
;; multi-session support came from https://github.com/stevemolitor/claude-code.el, Thanks Steve Molitor
;;; Code:
(require 'cl-lib)
(require 'project)
;; Silence native-compiler warnings.
(declare-function vterm "vterm" (&optional buffer-name))
(declare-function vterm-send-string "vterm" (&rest args))
(declare-function vterm-send-escape "vterm" ())
(declare-function vterm-send-return "vterm" ())
(declare-function vterm--window-adjust-process-window-size "vterm" (&rest args))
(declare-function vterm--filter "vterm" (&rest args))
(declare-function eat-term-send-string "eat" (&rest args))
(declare-function eat--adjust-process-window-size "eat" (&rest args))
(declare-function eat-mode "eat" ())
(declare-function eat-exec "eat" (&rest args))
(declare-function ai-code--session-handle-at-input "ai-code-input" ())
;; Declare vterm dynamic variables for let-binding to work with lexical-binding
(defvar vterm-shell)
(defvar vterm-environment)
(defvar vterm-kill-buffer-on-exit)
(defvar vterm-copy-mode)
(defvar eat-terminal)
(defvar eat--semi-char-mode)
;;; Customization
(defgroup ai-code-backends-infra nil
"Infrastructure for AI Code terminals."
:group 'tools)
(defcustom ai-code-backends-infra-terminal-backend 'vterm
"Terminal backend to use for sessions.
Can be either `vterm' or `eat'."
:type '(choice (const :tag "vterm" vterm)
(const :tag "eat" eat))
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-window-side 'right
"Side of the frame where the window should appear."
:type '(choice (const :tag "Left" left)
(const :tag "Right" right)
(const :tag "Top" top)
(const :tag "Bottom" bottom))
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-window-width 90
"Width of the side window when opened on left or right."
:type 'integer
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-window-height 20
"Height of the side window when opened on top or bottom."
:type 'integer
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-use-side-window t
"Whether to display the terminal in a side window."
:type 'boolean
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-focus-on-open t
"Whether to focus the terminal window when it opens."
:type 'boolean
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-vterm-anti-flicker t
"Enable intelligent flicker reduction for vterm display."
:type 'boolean
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-vterm-render-delay 0.005
"Rendering optimization delay for batched terminal updates."
:type 'number
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-terminal-initialization-delay 0.1
"Initialization delay for terminal stability."
:type 'number
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-prevent-reflow-glitch t
"Workaround for terminal scrolling bug #1422."
:type 'boolean
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-eat-preserve-position t
"Obsolete compatibility toggle for eat-specific reflow suppression.
Eat sessions now always use the terminal's native resize and redisplay
behavior because suppressing reflow can leave the screen stale when a
window becomes visible again."
:type 'boolean
:group 'ai-code-backends-infra)
;;; Variables
(defvar ai-code-backends-infra--processes (make-hash-table :test 'equal)
"Hash table mapping session keys to their processes.")
(defvar ai-code-backends-infra--last-accessed-buffer nil
"The most recently accessed AI Code buffer.")
(defvar ai-code-backends-infra--directory-buffer-map (make-hash-table :test 'equal)
"Hash table mapping (prefix . directory) to last selected session buffer.")
(defvar ai-code-backends-infra--file-session-map (make-hash-table :test 'equal)
"Hash table mapping (prefix . file) to attached AI session buffer.")
(defvar ai-code-backends-infra--preferred-session-buffer nil
"Preferred session buffer to place first when prompting for session selection.")
(defvar ai-code-backends-infra--reflow-advised-handlers nil
"Resize handlers currently advised with reflow filter.")
(defvar-local ai-code-backends-infra--idle-timer nil
"Timer for detecting idle state (response completion).")
(defvar-local ai-code-backends-infra--response-seen nil
"Non-nil when the current response has been observed.
Observation happens either by the buffer being visible or by a notification
being sent for the response completion.")
(defvar-local ai-code-backends-infra--last-meaningful-output-time nil
"Float timestamp of the most recent meaningful output.")
(defvar-local ai-code-backends-infra--session-directory nil
"Normalized working directory associated with the current session buffer.")
(defvar-local ai-code-backends-infra--session-terminal-backend nil
"Terminal backend used by the current session buffer.")
(defvar-local ai-code-backends-infra--multiline-input-sequence nil
"Terminal sequence sent for multiline input in the current session buffer.")
(defvar-local ai-code-backends-infra--terminal-active-cursor-type nil
"Cursor type to restore when returning to terminal interaction mode.")
(defvar-local ai-code-backends-infra--navigation-cursor-active nil
"Non-nil when Emacs temporarily owns the cursor for output navigation.")
(defvar ai-code-cli-args-history nil
"History list for CLI args prompts.")
(defcustom ai-code-backends-infra-idle-delay 5.0
"Delay in seconds of inactivity before considering response complete.
After this period of terminal inactivity, a notification may be sent
if the AI session buffer is not currently visible."
:type 'number
:group 'ai-code-backends-infra)
;;; Vterm Rendering Optimization
(defvar-local ai-code-backends-infra--vterm-render-queue nil)
(defvar-local ai-code-backends-infra--vterm-render-timer nil)
(defvar ai-code-backends-infra--vterm-advices-installed nil
"Flag indicating whether vterm filter advices have been installed globally.")
(declare-function ai-code-notifications-response-ready "ai-code-notifications" (&optional backend-name))
(defun ai-code-backends-infra--output-meaningful-p (output)
"Return non-nil when OUTPUT contains meaningful printable content."
(let* ((str (or output ""))
;; Strip OSC sequences (ESC ] ... BEL or ESC ] ... ESC \).
(str (replace-regexp-in-string "\x1b\\][^\x07\x1b]*\\(?:\x07\\|\x1b\\\\\\)" "" str))
;; Strip ANSI escape sequences.
(str (replace-regexp-in-string "\x1b\\[[0-9;?]*[ -/]*[@-~]" "" str))
;; Strip other control characters.
(str (replace-regexp-in-string "[\x00-\x1f\x7f]" "" str)))
(string-match-p "[^ \t\n\r]" str)))
(defun ai-code-backends-infra--buffer-user-visible-p (buffer)
"Return non-nil when BUFFER is visible in any live window."
(and (get-buffer-window-list buffer nil t) t))
(defun ai-code-backends-infra--check-response-complete (buffer)
"Check if AI response is complete in BUFFER and notify if enabled."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(if (ai-code-backends-infra--idle-delay-elapsed-p)
(let ((visible (ai-code-backends-infra--buffer-user-visible-p buffer)))
(if visible
(setq ai-code-backends-infra--response-seen t)
(when (not ai-code-backends-infra--response-seen)
(setq ai-code-backends-infra--response-seen t)
(when (require 'ai-code-notifications nil t)
(when (fboundp 'ai-code-notifications-response-ready)
(let ((buffer-name (buffer-name buffer)))
;; Extract backend name from buffer name format: *<backend>[<dir>]*
;; Example: "*codex[my-project]*" extracts "codex"
;; Regex breakdown:
;; \\* - matches literal asterisk
;; \\( - start capture group 1
;; [^[]+ - one or more chars that are not '['
;; \\) - end capture group 1 (this is the backend name)
;; \\[ - matches literal '['
(when (string-match "\\*\\([^[]+\\)\\[" buffer-name)
(let ((backend-name (match-string 1 buffer-name)))
(ai-code-notifications-response-ready backend-name)))))))))
(ai-code-backends-infra--schedule-idle-check)))))
(defun ai-code-backends-infra--schedule-idle-check ()
"Schedule a check for response completion after idle period.
The timer is reset only after meaningful output is observed."
(when ai-code-backends-infra--idle-timer
(cancel-timer ai-code-backends-infra--idle-timer))
(let ((buffer (current-buffer)))
(setq ai-code-backends-infra--idle-timer
(run-at-time ai-code-backends-infra-idle-delay nil
#'ai-code-backends-infra--check-response-complete
buffer))))
(defun ai-code-backends-infra--idle-delay-elapsed-p ()
"Return non-nil when idle delay has elapsed since last output."
(let ((last ai-code-backends-infra--last-meaningful-output-time))
(or (null last)
(>= (- (float-time) last) ai-code-backends-infra-idle-delay))))
(defun ai-code-backends-infra--note-meaningful-output ()
"Record meaningful output and schedule idle tracking."
(setq ai-code-backends-infra--response-seen nil
ai-code-backends-infra--last-meaningful-output-time (float-time))
(ai-code-backends-infra--schedule-idle-check))
(defun ai-code-backends-infra--vterm-notification-tracker (orig-fun process input)
"Track vterm activity for notification purposes, then call ORIG-FUN."
(when (ai-code-backends-infra--session-buffer-p (process-buffer process))
(with-current-buffer (process-buffer process)
(when (ai-code-backends-infra--output-meaningful-p input)
(ai-code-backends-infra--note-meaningful-output))))
(funcall orig-fun process input))
(defun ai-code-backends-infra--vterm-smart-renderer (orig-fun process input)
"Smart rendering filter for optimized vterm display updates.
Activity tracking for notifications is handled separately by
`ai-code-backends-infra--vterm-notification-tracker'.
Deferred rendering is suspended while `vterm-copy-mode' is active so that
scrolling and copying are not disrupted by timer-driven redraws."
(if (or (not ai-code-backends-infra-vterm-anti-flicker)
(not (ai-code-backends-infra--session-buffer-p (process-buffer process))))
(funcall orig-fun process input)
(with-current-buffer (process-buffer process)
(let* ((complex-redraw-detected
(string-match-p "\033\\[[0-9]*A.*\033\\[K.*\033\\[[0-9]*A.*\033\\[K" input))
(clear-count (1- (length (split-string input "\033\\[K"))))
(escape-count (cl-count ?\033 input))
(input-length (length input))
(escape-density (if (> input-length 0) (/ (float escape-count) input-length) 0)))
(if (or complex-redraw-detected
(and (> escape-density 0.3) (>= clear-count 2))
ai-code-backends-infra--vterm-render-queue
(bound-and-true-p vterm-copy-mode))
(progn
(setq ai-code-backends-infra--vterm-render-queue
(concat ai-code-backends-infra--vterm-render-queue input))
(when ai-code-backends-infra--vterm-render-timer
(cancel-timer ai-code-backends-infra--vterm-render-timer))
(setq ai-code-backends-infra--vterm-render-timer
(run-at-time ai-code-backends-infra-vterm-render-delay nil
(lambda (buf)
(when (buffer-live-p buf)
(with-current-buffer buf
;; Clear timer reference regardless of whether we render.
(setq ai-code-backends-infra--vterm-render-timer nil)
(when (and ai-code-backends-infra--vterm-render-queue
(not (bound-and-true-p vterm-copy-mode)))
(let ((inhibit-redisplay t)
(data ai-code-backends-infra--vterm-render-queue))
(setq ai-code-backends-infra--vterm-render-queue nil)
(funcall orig-fun (get-buffer-process buf) data))))))
(current-buffer))))
(funcall orig-fun process input))))))
(defun ai-code-backends-infra--vterm-flush-on-copy-mode-exit ()
"Flush any pending render queue when exiting `vterm-copy-mode'.
Added buffer-locally to `vterm-copy-mode-hook' so that terminal output
queued while copy mode was active is rendered immediately when the user
returns to normal terminal interaction."
(unless (bound-and-true-p vterm-copy-mode)
(when ai-code-backends-infra--vterm-render-queue
(when-let ((proc (get-buffer-process (current-buffer))))
(let ((data ai-code-backends-infra--vterm-render-queue))
(setq ai-code-backends-infra--vterm-render-queue nil)
(vterm--filter proc data))))))
(defun ai-code-backends-infra--configure-vterm-buffer ()
"Configure vterm for enhanced performance."
(setq-local vterm-scroll-to-bottom-on-output nil)
(when (boundp 'vterm--redraw-immididately)
(setq-local vterm--redraw-immididately nil))
(when (fboundp 'ai-code--session-handle-at-input)
(local-set-key (kbd "@") #'ai-code--session-handle-at-input))
(when (fboundp 'ai-code--session-handle-hash-input)
(local-set-key (kbd "#") #'ai-code--session-handle-hash-input))
(setq-local cursor-in-non-selected-windows nil)
(setq-local blink-cursor-mode nil)
(setq-local cursor-type nil)
(when-let ((proc (get-buffer-process (current-buffer))))
(set-process-query-on-exit-flag proc nil)
(when (fboundp 'process-put)
(process-put proc 'read-output-max 4096)))
;; Flush queued render output when the user exits vterm-copy-mode.
(add-hook 'vterm-copy-mode-hook
#'ai-code-backends-infra--vterm-flush-on-copy-mode-exit nil t)
;; Hand cursor ownership to Emacs while browsing frozen terminal output.
(ai-code-backends-infra--install-navigation-cursor-sync)
;; Install vterm filter advices globally (only once)
(unless ai-code-backends-infra--vterm-advices-installed
;; Always install notification tracker for session buffers
(advice-add 'vterm--filter :around #'ai-code-backends-infra--vterm-notification-tracker)
;; Conditionally install anti-flicker renderer
(when ai-code-backends-infra-vterm-anti-flicker
(advice-add 'vterm--filter :around #'ai-code-backends-infra--vterm-smart-renderer))
(setq ai-code-backends-infra--vterm-advices-installed t)))
;;; Terminal Backend Abstraction
(defun ai-code-backends-infra--terminal-ensure-backend ()
"Ensure the selected terminal backend is available."
(cond
((eq ai-code-backends-infra-terminal-backend 'vterm)
(unless (featurep 'vterm) (require 'vterm nil t))
(unless (featurep 'vterm)
(user-error "The package vterm is not installed")))
((eq ai-code-backends-infra-terminal-backend 'eat)
(unless (featurep 'eat) (require 'eat nil t))
(unless (featurep 'eat)
(user-error "The package eat is not installed")))
(t (user-error "Invalid terminal backend: %s" ai-code-backends-infra-terminal-backend)))
;; Keep reflow advice synchronized with current backend/settings.
(ai-code-backends-infra--sync-reflow-filter-advice))
(defun ai-code-backends-infra--current-terminal-backend ()
"Return terminal backend for current buffer operations."
(or ai-code-backends-infra--session-terminal-backend
ai-code-backends-infra-terminal-backend))
(defun ai-code-backends-infra--terminal-navigation-mode-p ()
"Return non-nil when the current terminal buffer is in navigation mode."
(pcase (ai-code-backends-infra--current-terminal-backend)
('vterm (bound-and-true-p vterm-copy-mode))
('eat (and (bound-and-true-p eat-terminal)
(or buffer-read-only
(not (bound-and-true-p eat--semi-char-mode)))))
(_ nil)))
(defun ai-code-backends-infra--sync-terminal-cursor ()
"Hand cursor ownership between the terminal and Emacs navigation modes."
(if (ai-code-backends-infra--terminal-navigation-mode-p)
(unless ai-code-backends-infra--navigation-cursor-active
(setq ai-code-backends-infra--terminal-active-cursor-type cursor-type
ai-code-backends-infra--navigation-cursor-active t
cursor-type t))
(when ai-code-backends-infra--navigation-cursor-active
(setq cursor-type ai-code-backends-infra--terminal-active-cursor-type
ai-code-backends-infra--navigation-cursor-active nil))))
(defun ai-code-backends-infra--install-navigation-cursor-sync ()
"Install buffer-local hooks for cursor handoff in terminal navigation modes."
(pcase (ai-code-backends-infra--current-terminal-backend)
('vterm
(add-hook 'vterm-copy-mode-hook
#'ai-code-backends-infra--sync-terminal-cursor nil t))
('eat
(add-hook 'post-command-hook
#'ai-code-backends-infra--sync-terminal-cursor nil t))))
(defun ai-code-backends-infra--terminal-dispatch (vterm-fn eat-fn)
"Run VTERM-FN or EAT-FN based on selected terminal backend."
(pcase (ai-code-backends-infra--current-terminal-backend)
('vterm (funcall vterm-fn))
('eat (funcall eat-fn))
(_ (error "Unknown terminal backend: %s"
(ai-code-backends-infra--current-terminal-backend)))))
(defun ai-code-backends-infra--terminal-send-string (string)
"Send STRING to the terminal in the current buffer."
(ai-code-backends-infra--terminal-dispatch
(lambda () (vterm-send-string string))
(lambda ()
(when (bound-and-true-p eat-terminal)
(eat-term-send-string eat-terminal string)))))
(defun ai-code-backends-infra--terminal-send-escape ()
"Send escape key to the terminal in the current buffer."
(ai-code-backends-infra--terminal-dispatch
(lambda () (vterm-send-escape))
(lambda ()
(when (bound-and-true-p eat-terminal)
(eat-term-send-string eat-terminal "\e")))))
(defun ai-code-backends-infra--terminal-send-return ()
"Send return key to the terminal in the current buffer."
(ai-code-backends-infra--terminal-dispatch
(lambda () (vterm-send-return))
(lambda ()
(when (bound-and-true-p eat-terminal)
(eat-term-send-string eat-terminal "\r")))))
(defun ai-code-backends-infra--terminal-send-backspace ()
"Send backspace key to the terminal in the current buffer."
(ai-code-backends-infra--terminal-dispatch
(lambda () (vterm-send-string "\177"))
(lambda ()
(when (bound-and-true-p eat-terminal)
(eat-term-send-string eat-terminal "\177")))))
(defun ai-code-backends-infra--terminal-send-multiline-input ()
"Send the configured multiline-input sequence for the current session buffer."
(interactive)
(unless ai-code-backends-infra--multiline-input-sequence
(user-error "No multiline input sequence configured for this session"))
(ai-code-backends-infra--terminal-send-string
ai-code-backends-infra--multiline-input-sequence))
(defun ai-code-backends-infra--configure-multiline-input (sequence)
"Configure multiline input keybindings in the current session buffer.
SEQUENCE is the terminal sequence sent for `S-<return>' and `C-<return>'."
(when sequence
(setq-local ai-code-backends-infra--multiline-input-sequence sequence)
(local-set-key (kbd "S-<return>")
#'ai-code-backends-infra--terminal-send-multiline-input)
(local-set-key (kbd "C-<return>")
#'ai-code-backends-infra--terminal-send-multiline-input)))
(defun ai-code-backends-infra--configure-session-buffer (buffer
&optional escape-fn
multiline-input-sequence)
"Configure BUFFER with shared session keybindings.
ESCAPE-FN is bound to `C-<escape>' when non-nil.
MULTILINE-INPUT-SEQUENCE configures `S-<return>' and `C-<return>' when non-nil."
(with-current-buffer buffer
(when escape-fn
(local-set-key (kbd "C-<escape>") escape-fn))
(ai-code-backends-infra--configure-multiline-input
multiline-input-sequence)))
;;; Reflow and Window Management
(defun ai-code-backends-infra--terminal-resize-handler ()
"Retrieve the terminal's resize handling function based on backend."
(pcase ai-code-backends-infra-terminal-backend
('vterm #'vterm--window-adjust-process-window-size)
('eat #'eat--adjust-process-window-size)
(_ (error "Unsupported terminal backend"))))
(defun ai-code-backends-infra--session-buffer-p (buffer)
"Check if BUFFER belongs to an AI session."
(when-let ((name (if (stringp buffer) buffer (buffer-name buffer))))
(string-match-p "\\`\\*.*\\[.*\\].*\\*\\'" name)))
(defun ai-code-backends-infra--terminal-reflow-filter (original-fn &rest args)
"Filter terminal reflows to prevent height-only resize triggers."
(let* ((base-result (apply original-fn args))
(dimensions-stable t))
(dolist (win (window-list))
(when-let* ((buf (window-buffer win))
((ai-code-backends-infra--session-buffer-p buf)))
(let* ((new-width (window-width win))
(cached-width (window-parameter win 'ai-code-backends-infra-cached-width)))
(unless (eql new-width cached-width)
(setq dimensions-stable nil)
(set-window-parameter win 'ai-code-backends-infra-cached-width new-width)))))
(if (and ai-code-backends-infra-prevent-reflow-glitch dimensions-stable)
nil
base-result)))
(defun ai-code-backends-infra--sync-reflow-filter-advice ()
"Add or remove terminal reflow advice according to current settings."
(let* ((resize-handler (ai-code-backends-infra--terminal-resize-handler))
(enabled (and ai-code-backends-infra-prevent-reflow-glitch
(eq ai-code-backends-infra-terminal-backend 'vterm))))
(dolist (handler (cl-copy-list ai-code-backends-infra--reflow-advised-handlers))
(unless (and enabled (eq handler resize-handler))
(when (advice-member-p #'ai-code-backends-infra--terminal-reflow-filter
handler)
(advice-remove handler
#'ai-code-backends-infra--terminal-reflow-filter))
(setq ai-code-backends-infra--reflow-advised-handlers
(delq handler ai-code-backends-infra--reflow-advised-handlers))))
(when (and enabled resize-handler)
(unless (advice-member-p #'ai-code-backends-infra--terminal-reflow-filter
resize-handler)
(advice-add resize-handler
:around
#'ai-code-backends-infra--terminal-reflow-filter))
(cl-pushnew resize-handler ai-code-backends-infra--reflow-advised-handlers
:test #'eq))))
(defun ai-code-backends-infra--display-buffer-in-side-window (buffer)
"Display BUFFER in a side window."
(let ((window
(if ai-code-backends-infra-use-side-window
(let* ((side ai-code-backends-infra-window-side)
(display-buffer-alist
`((,(regexp-quote (buffer-name buffer))
(display-buffer-in-side-window)
(side . ,side)
(slot . 0)
,@(when (memq side '(left right))
`((window-width . ,ai-code-backends-infra-window-width)))
,@(when (memq side '(top bottom))
`((window-height . ,ai-code-backends-infra-window-height)))
(window-parameters . ((no-delete-other-windows . t)))))))
(display-buffer buffer))
(display-buffer buffer))))
(setq ai-code-backends-infra--last-accessed-buffer buffer)
(when (and window ai-code-backends-infra-focus-on-open)
(select-window window))
window))
;;; Session Helpers
(defun ai-code-backends-infra--session-working-directory ()
"Return the working directory, preferring the current project root."
(if-let ((project (project-current)))
(expand-file-name (project-root project))
(expand-file-name default-directory)))
(defun ai-code-backends-infra--normalize-session-directory (directory)
"Return DIRECTORY normalized for robust session matching."
(file-name-as-directory (expand-file-name directory)))
(defun ai-code-backends-infra--normalize-file-path (file)
"Return normalized absolute path for FILE."
(expand-file-name file))
(defun ai-code-backends-infra--file-session-map-key (prefix source-buffer)
"Return file-session map key for PREFIX and SOURCE-BUFFER."
(when (and prefix (buffer-live-p source-buffer))
(with-current-buffer source-buffer
(when (and (stringp buffer-file-name)
(> (length buffer-file-name) 0))
(cons prefix
(ai-code-backends-infra--normalize-file-path buffer-file-name))))))
(defun ai-code-backends-infra--remember-file-session-buffer (prefix source-buffer session-buffer)
"Remember SESSION-BUFFER as attached session for SOURCE-BUFFER and PREFIX."
(when-let ((key (ai-code-backends-infra--file-session-map-key prefix source-buffer)))
(if (and session-buffer (buffer-live-p session-buffer))
(puthash key session-buffer ai-code-backends-infra--file-session-map)
(remhash key ai-code-backends-infra--file-session-map))))
(defun ai-code-backends-infra--attached-file-session (prefix source-buffer working-dir)
"Return attached session state for PREFIX, SOURCE-BUFFER and WORKING-DIR.
Return a cons of (BUFFER . MISSING-P)."
(let ((key (ai-code-backends-infra--file-session-map-key prefix source-buffer)))
(if (null key)
(cons nil nil)
(let* ((attached (gethash key ai-code-backends-infra--file-session-map))
(valid (and (buffer-live-p attached)
(memq attached
(ai-code-backends-infra--find-session-buffers
prefix
working-dir)))))
(cond
(valid
(cons attached nil))
(attached
(remhash key ai-code-backends-infra--file-session-map)
(cons nil t))
(t
(cons nil nil)))))))
(defun ai-code-backends-infra--resolve-session-buffer (buffer-name missing-message prefix working-dir
force-prompt source-buffer)
"Resolve session buffer using BUFFER-NAME or selection rules.
MISSING-MESSAGE is used when no target session exists.
When PREFIX and WORKING-DIR are present, prefer the attached session for
SOURCE-BUFFER unless FORCE-PROMPT is non-nil."
(let* ((file-key (and prefix
source-buffer
(ai-code-backends-infra--file-session-map-key
prefix
source-buffer)))
(attached-state (and prefix working-dir
(ai-code-backends-infra--attached-file-session
prefix
source-buffer
working-dir)))
(attached-buffer (car-safe attached-state))
(attached-missing (cdr-safe attached-state))
(needs-initial-file-selection (and (null buffer-name)
file-key
(null attached-buffer)
(not attached-missing)))
(effective-force-prompt (or force-prompt
attached-missing
needs-initial-file-selection))
(buffer (or (and buffer-name (get-buffer buffer-name))
(and attached-buffer (not force-prompt) attached-buffer)
(and prefix working-dir
(let ((ai-code-backends-infra--preferred-session-buffer
attached-buffer))
(ai-code-backends-infra--select-session-buffer
prefix
working-dir
effective-force-prompt))))))
(when (and attached-missing (null buffer-name))
(message "Attached AI session for this file no longer exists. Please select a target session again."))
(if buffer
(progn
(ai-code-backends-infra--remember-session-buffer prefix working-dir buffer)
(ai-code-backends-infra--remember-file-session-buffer prefix source-buffer buffer)
buffer)
(user-error "%s" missing-message))))
(defun ai-code-backends-infra--set-session-directory (buffer directory)
"Store DIRECTORY on BUFFER for exact session matching."
(when (and (buffer-live-p buffer) (stringp directory))
(with-current-buffer buffer
(setq-local ai-code-backends-infra--session-directory
(ai-code-backends-infra--normalize-session-directory directory)))))
(defun ai-code-backends-infra--buffer-session-directory (buffer)
"Return BUFFER session directory, using legacy `default-directory' as fallback."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(cond
((and (stringp ai-code-backends-infra--session-directory)
(> (length ai-code-backends-infra--session-directory) 0))
(ai-code-backends-infra--normalize-session-directory
ai-code-backends-infra--session-directory))
((and (stringp default-directory)
(> (length default-directory) 0))
(ai-code-backends-infra--normalize-session-directory
default-directory))
(t nil)))))
(defun ai-code-backends-infra--session-buffer-name (prefix directory &optional instance-name)
"Return a session buffer name for PREFIX in DIRECTORY.
When INSTANCE-NAME is non-nil and not \"default\", include it in the name."
(let* ((base (file-name-nondirectory (directory-file-name directory)))
(instance (and instance-name
(not (string= instance-name ""))
(not (string= instance-name "default"))
instance-name)))
(format "*%s[%s%s]*"
prefix
base
(if instance (format ":%s" instance) ""))))
(defun ai-code-backends-infra--normalize-instance-name (instance-name)
"Return a normalized INSTANCE-NAME, defaulting to \"default\"."
(if (and instance-name (not (string= instance-name "")))
instance-name
"default"))
(defun ai-code-backends-infra--session-key (directory instance-name)
"Return a session key for DIRECTORY and INSTANCE-NAME."
(cons directory (ai-code-backends-infra--normalize-instance-name instance-name)))
(defun ai-code-backends-infra--session-map-key (prefix directory)
"Return a map key for PREFIX and DIRECTORY."
(cons prefix (expand-file-name directory)))
(defun ai-code-backends-infra--parse-session-buffer-name (buffer-name prefix)
"Parse BUFFER-NAME for PREFIX.
Return a cons of (base-name . instance-name) or nil."
(when (string-match
(format "\\`\\*%s\\[\\([^]:]+\\)\\(?::\\([^]]+\\)\\)?\\]\\*\\'"
(regexp-quote prefix))
buffer-name)
(cons (match-string 1 buffer-name)
(match-string 2 buffer-name))))
(defun ai-code-backends-infra--session-instance-name (buffer-name prefix)
"Return instance name for BUFFER-NAME with PREFIX."
(when-let ((parsed (ai-code-backends-infra--parse-session-buffer-name buffer-name prefix)))
(ai-code-backends-infra--normalize-instance-name (cdr parsed))))
(defun ai-code-backends-infra--find-session-buffers (prefix directory)
"Return session buffers for PREFIX in DIRECTORY."
(let ((base (file-name-nondirectory (directory-file-name directory)))
(target-directory (ai-code-backends-infra--normalize-session-directory directory)))
(cl-remove-if-not
(lambda (buf)
(when-let ((parsed (ai-code-backends-infra--parse-session-buffer-name
(buffer-name buf)
prefix)))
(if-let ((buffer-directory (ai-code-backends-infra--buffer-session-directory buf)))
(string= (ai-code-backends-infra--normalize-session-directory buffer-directory)
target-directory)
(string= (car parsed) base))))
(buffer-list))))
(defun ai-code-backends-infra--remember-session-buffer (prefix directory buffer)
"Remember BUFFER as the last selected session for PREFIX and DIRECTORY."
(when (and prefix directory buffer)
(puthash (ai-code-backends-infra--session-map-key prefix directory)
buffer
ai-code-backends-infra--directory-buffer-map)))
(defun ai-code-backends-infra--forget-session-buffer (prefix directory buffer)
"Forget BUFFER if it's the remembered session for PREFIX and DIRECTORY."
(when (and prefix directory buffer)
(let* ((key (ai-code-backends-infra--session-map-key prefix directory))
(existing (gethash key ai-code-backends-infra--directory-buffer-map)))
(when (eq existing buffer)
(remhash key ai-code-backends-infra--directory-buffer-map)))))
(defun ai-code-backends-infra--select-session-buffer (prefix directory &optional force-prompt)
"Select a session buffer for PREFIX in DIRECTORY.
Returns the selected buffer or nil if none exist."
(let ((buffers (ai-code-backends-infra--find-session-buffers prefix directory)))
(cond
((null buffers) nil)
((= (length buffers) 1)
(ai-code-backends-infra--remember-session-buffer prefix directory (car buffers))
(car buffers))
(t
(let* ((remembered (gethash (ai-code-backends-infra--session-map-key prefix directory)
ai-code-backends-infra--directory-buffer-map))
(preferred (if (memq ai-code-backends-infra--preferred-session-buffer buffers)
ai-code-backends-infra--preferred-session-buffer
(and (memq remembered buffers) remembered)))
(ordered-buffers (if preferred
(cons preferred (delq preferred (copy-sequence buffers)))
buffers))
(choices (mapcar (lambda (buf)
(cons (ai-code-backends-infra--session-instance-name
(buffer-name buf)
prefix)
buf))
ordered-buffers))
(candidates (mapcar #'car choices))
(default-candidate (car candidates)))
(if (and (not force-prompt) remembered (memq remembered buffers))
remembered
(let ((selection (completing-read
(format "Select %s session: " prefix)
candidates
nil t nil nil default-candidate)))
(let ((buffer (cdr (assoc selection choices))))
(ai-code-backends-infra--remember-session-buffer prefix directory buffer)
buffer))))))))
(defun ai-code-backends-infra--prompt-for-instance-name (existing-instance-names &optional force-prompt)
"Prompt for a new instance name.
EXISTING-INSTANCE-NAMES is a list of existing instance names.
If FORCE-PROMPT is nil and there are no existing instances, return \"default\"."
(if (or existing-instance-names force-prompt)
(let ((proposed-name ""))
(while (or (string= proposed-name "")
(member proposed-name existing-instance-names))
(setq proposed-name
(read-string (if existing-instance-names
(format "Instance name (existing: %s): "
(mapconcat #'identity existing-instance-names ", "))
"Instance name: ")
nil nil (and (> (length proposed-name) 0) proposed-name)))
(cond
((string= proposed-name "")
(message "Instance name cannot be empty. Please enter a name.")
(sit-for 1))
((member proposed-name existing-instance-names)
(message "Instance name '%s' already exists. Please choose a different name." proposed-name)
(sit-for 1))))
proposed-name)
"default"))
(defun ai-code-backends-infra--resolve-start-command (program switches arg &optional prompt-label)
"Build command string for PROGRAM and SWITCHES.
When ARG is non-nil, prompt for CLI args using SWITCHES as default input.
PROMPT-LABEL is used in the minibuffer prompt."
(let* ((default-args (mapconcat #'identity switches " "))
(prompt (format "%s args: " (or prompt-label "CLI")))
(prompt-args (when arg
(read-string prompt default-args 'ai-code-cli-args-history)))
(resolved-args (if arg
(split-string-shell-command prompt-args)
switches))
(command (mapconcat #'identity
(cons program resolved-args)
" ")))
(list :command command :args resolved-args)))
(defun ai-code-backends-infra--cleanup-session (directory buffer-name process-table
&optional instance-name prefix event)
"Clean up a session for DIRECTORY using BUFFER-NAME and PROCESS-TABLE.
EVENT is the process sentinel event string. When EVENT is non-nil and does
not start with \"finished\", the buffer is preserved so the user can inspect
any error output left behind by the CLI."
(let* ((resolved-instance (or instance-name
(and prefix
(ai-code-backends-infra--session-instance-name
buffer-name
prefix))
"default"))
(key (ai-code-backends-infra--session-key directory resolved-instance)))
(remhash key process-table))
(when-let ((buffer (get-buffer buffer-name)))
(ai-code-backends-infra--forget-session-buffer prefix directory buffer)
(when (buffer-live-p buffer)
(when (or (null event)
(string-prefix-p "finished" event))
(kill-buffer buffer)))))
(defun ai-code-backends-infra--toggle-or-create-session (working-dir buffer-name process-table command
&optional escape-fn cleanup-fn
instance-name prefix force-prompt
env-vars multiline-input-sequence
post-start-fn)
"Toggle or create a terminal session.
WORKING-DIR is the directory for the session.
BUFFER-NAME is the terminal buffer name.
PROCESS-TABLE maps session keys to processes.
COMMAND is the shell command to run.
ESCAPE-FN is bound to `C-<escape>' inside the session buffer when non-nil.
CLEANUP-FN is called with no arguments when the process exits.
INSTANCE-NAME overrides instance selection when non-nil.
PREFIX enables instance selection when BUFFER-NAME is nil.
When FORCE-PROMPT is non-nil, always prompt for a new instance name.
ENV-VARS is a list of additional environment variable strings (e.g., \"VAR=value\")
passed to the terminal session on creation.
MULTILINE-INPUT-SEQUENCE configures `S-<return>' and `C-<return>' to send
that sequence inside the session buffer.
POST-START-FN is called with (BUFFER PROCESS INSTANCE-NAME) after a new
session starts successfully."
(setq process-table (or process-table ai-code-backends-infra--processes))
(ai-code-backends-infra--cleanup-dead-processes process-table)
(let* ((existing-buffers (and prefix
(ai-code-backends-infra--find-session-buffers
prefix
working-dir)))
(existing-instance-names (mapcar (lambda (buf)
(ai-code-backends-infra--session-instance-name
(buffer-name buf)
prefix))
existing-buffers))
(resolved-instance (cond
(instance-name (ai-code-backends-infra--normalize-instance-name instance-name))
(prefix
(ai-code-backends-infra--prompt-for-instance-name
existing-instance-names
force-prompt))
(t "default")))
(resolved-buffer-name (or buffer-name
(and prefix
(ai-code-backends-infra--session-buffer-name
prefix
working-dir
resolved-instance))))
(session-key (ai-code-backends-infra--session-key working-dir resolved-instance))
(existing-process (gethash session-key process-table))
(buffer (get-buffer resolved-buffer-name)))
(if (and existing-process (process-live-p existing-process) buffer)
(if (get-buffer-window buffer)
(delete-window (get-buffer-window buffer))
(progn
(ai-code-backends-infra--set-session-directory buffer working-dir)
(ai-code-backends-infra--configure-session-buffer
buffer nil multiline-input-sequence)
(ai-code-backends-infra--remember-session-buffer prefix working-dir buffer)
(ai-code-backends-infra--display-buffer-in-side-window buffer)))
(let* ((buffer-and-process
(ai-code-backends-infra--create-terminal-session
resolved-buffer-name working-dir command env-vars))
(new-buffer (car buffer-and-process))
(process (cdr buffer-and-process)))
(puthash session-key process process-table)
;; Wait for initialization before checking process status
(sleep-for ai-code-backends-infra-terminal-initialization-delay)
;; Check if process is still alive after initialization delay
(if (and process (process-live-p process))
(progn
;; Process started successfully, set up sentinel for cleanup on exit
(set-process-sentinel
process
(lambda (_proc event)
(ai-code-backends-infra--cleanup-session
working-dir
resolved-buffer-name
process-table
resolved-instance
prefix
event)
(when cleanup-fn
(funcall cleanup-fn))))
(ai-code-backends-infra--configure-session-buffer
new-buffer escape-fn multiline-input-sequence)
(when post-start-fn
(funcall post-start-fn new-buffer process resolved-instance))
(with-current-buffer new-buffer
(add-hook 'kill-buffer-hook
(lambda ()
(ai-code-backends-infra--forget-session-buffer
prefix
working-dir
(current-buffer)))
nil t))
(ai-code-backends-infra--remember-session-buffer prefix working-dir new-buffer)
(ai-code-backends-infra--display-buffer-in-side-window new-buffer))
;; Process exited during initialization - show buffer with error to user
;; Clean up the session from process table (but keep buffer visible)
(remhash session-key process-table)
;; Display the buffer so user can see the error output
(if (buffer-live-p new-buffer)
(progn
(pop-to-buffer new-buffer)
(message "CLI failed to start - see buffer for error details"))
(message "CLI failed to start - process exited immediately")))))))
(defun ai-code-backends-infra--switch-to-session-buffer (buffer-name missing-message
&optional prefix working-dir force-prompt)
"Switch to BUFFER-NAME or signal MISSING-MESSAGE.
When PREFIX and WORKING-DIR are provided, select from multiple sessions."
(let* ((source-buffer (current-buffer))
(buffer (ai-code-backends-infra--resolve-session-buffer
buffer-name
missing-message
prefix
working-dir
force-prompt
source-buffer)))
(if-let ((window (get-buffer-window buffer)))
(select-window window)
(ai-code-backends-infra--display-buffer-in-side-window buffer))))
(defun ai-code-backends-infra--send-line-to-session (buffer-name missing-message line
&optional prefix working-dir force-prompt)
"Send LINE to BUFFER-NAME or signal MISSING-MESSAGE.
When PREFIX and WORKING-DIR are provided, select from multiple sessions."
(let* ((source-buffer (current-buffer))
(buffer (ai-code-backends-infra--resolve-session-buffer
buffer-name
missing-message
prefix
working-dir
force-prompt
source-buffer)))
(with-current-buffer buffer
(ai-code-backends-infra--remember-session-buffer prefix working-dir buffer)
(ai-code-backends-infra--terminal-send-string line)
(sit-for 0.5) ;; 0.1 might be too low for some cli backends such as github copilot cli
(ai-code-backends-infra--terminal-send-return))))
;;; Generic Session Creation
(defun ai-code-backends-infra--create-terminal-session (buffer-name working-dir command env-vars)
"Generic function to create a terminal session.
BUFFER-NAME is the name for the buffer.
WORKING-DIR is the directory.
COMMAND is the shell command to run.
ENV-VARS is a list of environment variables."
(ai-code-backends-infra--terminal-ensure-backend)
(let ((default-directory working-dir))
(cond
((eq ai-code-backends-infra-terminal-backend 'vterm)
(let* ((vterm-shell command)
(vterm-kill-buffer-on-exit nil) ; Keep buffer alive to show errors
(vterm-environment (append env-vars (bound-and-true-p vterm-environment))))
(let ((buffer (save-window-excursion (vterm buffer-name))))
(ai-code-backends-infra--set-session-directory buffer working-dir)
(with-current-buffer buffer
(setq-local ai-code-backends-infra--session-terminal-backend 'vterm)
(ai-code-backends-infra--configure-vterm-buffer))
(cons buffer (get-buffer-process buffer)))))
((eq ai-code-backends-infra-terminal-backend 'eat)
(let* ((buffer (get-buffer-create buffer-name))
(parts (split-string-shell-command command))
(program (car parts))
(args (cdr parts)))
(ai-code-backends-infra--set-session-directory buffer working-dir)
(with-current-buffer buffer
(setq-local ai-code-backends-infra--session-terminal-backend 'eat)
(unless (eq major-mode 'eat-mode) (eat-mode))
(when (fboundp 'ai-code--session-handle-at-input)
(local-set-key (kbd "@") #'ai-code--session-handle-at-input))
(when (fboundp 'ai-code--session-handle-hash-input)
(local-set-key (kbd "#") #'ai-code--session-handle-hash-input))
(ai-code-backends-infra--install-navigation-cursor-sync)
(setq-local process-environment (append env-vars process-environment))
(eat-exec buffer buffer-name program nil args)
;; Add process filter to track activity for notifications
(when-let ((proc (get-buffer-process buffer)))
(let ((orig-filter (process-filter proc)))
(set-process-filter
proc
(lambda (process output)
;; Call original filter first
(when orig-filter
(funcall orig-filter process output))