-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathai-code-backends-infra.el
More file actions
1172 lines (1057 loc) · 56.6 KB
/
ai-code-backends-infra.el
File metadata and controls
1172 lines (1057 loc) · 56.6 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, Silex, Steve Molitor, AI Agent
;; 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)
(require 'ai-code-session-link)
;; Terminal-specific implementations live in dedicated modules so this
;; file can stay focused on shared session orchestration.
(require 'ai-code-backends-infra-vterm)
(require 'ai-code-backends-infra-eat)
(require 'ai-code-backends-infra-ghostel)
(declare-function ai-code--session-handle-at-input "ai-code-input" ())
;;; 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', `eat', or `ghostel'."
:type '(choice (const :tag "vterm" vterm)
(const :tag "eat" eat)
(const :tag "ghostel" ghostel))
: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
"Body 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-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-strip-alternate-screen t
"Strip alternate screen buffer sequences from terminal output.
When non-nil, remove the escape sequences that switch to and from
the alternate screen buffer (\\e[?1049h and \\e[?1049l). TUI
applications like GitHub Copilot CLI hardcode these sequences,
which disables terminal scrollback. Stripping them forces output
onto the normal screen buffer where scrollback is preserved."
:type 'boolean
:group 'ai-code-backends-infra)
(defcustom ai-code-backends-infra-scrollback-inject-interval 1.0
"Minimum seconds between scrollback-preservation injections.
When a TUI application redraws via synchronized output, we inject
newlines to push visible content into the scrollback ring. This
interval prevents flooding the scrollback with duplicate frames."
:type 'number
: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)
;;; Terminal Output Preservation
(defconst ai-code-backends-infra--alternate-screen-regexp
"\033\\[\\?1049[hl]"
"Regexp matching alternate screen buffer enter/exit sequences.
Matches \\e[?1049h (enter) and \\e[?1049l (exit).")
(defconst ai-code-backends-infra--screen-clear-regexp
"\033\\[2J"
"Regexp matching the Erase Display (ED 2) screen clear sequence.")
(defconst ai-code-backends-infra--scrollback-clear-regexp
"\033\\[3J"
"Regexp matching the Erase Display (ED 3) scrollback clear sequence.")
(defconst ai-code-backends-infra--sync-redraw-regexp
"\033\\[\\?2026h\033\\[1;1H"
"Regexp matching synchronized-update frame start with cursor home.
Copilot CLI uses \\e[?2026h (begin synchronized update) followed
immediately by \\e[1;1H (cursor to row 1, col 1) to redraw the
entire screen in place.")
(defvar ai-code-backends-infra-strip-alternate-screen-debug nil
"When non-nil, log alternate-screen filter matches to *Messages*.
Set to t to debug scrollback-preservation transformations.")
(defvar-local ai-code-backends-infra--last-scrollback-inject-time 0
"Timestamp of the last scrollback-preservation injection.
Used to throttle injections per `ai-code-backends-infra-scrollback-inject-interval'.")
(defvar-local ai-code-backends-infra--sync-redraw-scrollback nil
"When non-nil, inject scrollback-preserving newlines before
synchronized-update frame redraws (\\e[?2026h\\e[1;1H).
Backends that hardcode alternate screen buffer should set this
to t via their post-start-fn.")
(defun ai-code-backends-infra--scroll-to-scrollback-sequence ()
"Return a sequence that pushes visible content into scrollback.
Move cursor to the last row, emit enough newlines to scroll all
visible lines into the scrollback buffer, then cursor home."
(let ((height (or (when-let ((win (get-buffer-window (current-buffer) t)))
(window-body-height win))
50)))
(concat "\033[" (number-to-string height) ";1H"
(make-string height ?\n)
"\033[H")))
(defun ai-code-backends-infra--strip-alternate-screen-sequences (str)
"Normalize terminal sequences in STR to preserve scrollback.
When `ai-code-backends-infra-strip-alternate-screen' is non-nil and
the current buffer is an AI session buffer, apply these transformations:
1. Strip alternate screen enter/exit (\\e[?1049h, \\e[?1049l).
2. Convert screen clear (\\e[2J) to a newline-scroll sequence so
the current content is pushed into scrollback. Throttled via
`ai-code-backends-infra-scrollback-inject-interval'.
3. Strip scrollback clear (\\e[3J).
4. Convert erase-to-end (\\e[J / \\e[0J) preceded by cursor-home
into a scrollback-preserving sequence. Throttled to avoid
flooding the scrollback ring with repeated TUI frames.
5. Detect synchronized-update frame redraws (\\e[?2026h\\e[1;1H)
and inject scrollback preservation before the frame, throttled
to avoid flooding."
(if (and ai-code-backends-infra-strip-alternate-screen
(ai-code-backends-infra--session-buffer-p (current-buffer)))
(let* ((result str)
(scroll-seq (ai-code-backends-infra--scroll-to-scrollback-sequence))
(now (float-time))
(inject-ok (>= (- now
ai-code-backends-infra--last-scrollback-inject-time)
ai-code-backends-infra-scrollback-inject-interval)))
(when (and ai-code-backends-infra-strip-alternate-screen-debug
(string-match-p "\033\\[" str))
(let ((visible (replace-regexp-in-string
"\033" "<ESC>" (substring str 0 (min 200 (length str))))))
(message "alt-screen-filter: len=%d alt=%s 2J=%s 3J=%s H+J=%s sync=%s seq=[%s]"
(length str)
(if (string-match-p ai-code-backends-infra--alternate-screen-regexp str) "Y" "N")
(if (string-match-p ai-code-backends-infra--screen-clear-regexp str) "Y" "N")
(if (string-match-p ai-code-backends-infra--scrollback-clear-regexp str) "Y" "N")
(if (string-match-p "\033\\[H.*\033\\[J\\|\033\\[H.*\033\\[0J" str) "Y" "N")
(if (string-match-p ai-code-backends-infra--sync-redraw-regexp str) "Y" "N")
visible)))
;; 1. Strip alternate screen transitions.
(setq result (replace-regexp-in-string
ai-code-backends-infra--alternate-screen-regexp "" result))
;; 2. Convert ED 2 (clear screen) to newline-scroll, throttled
;; to avoid flooding the scrollback ring with repeated TUI
;; frames. When throttled the plain \e[2J clears the screen
;; without preserving content, which is acceptable since a
;; recent snapshot was already pushed.
(when (and inject-ok
(string-match-p ai-code-backends-infra--screen-clear-regexp result))
(setq ai-code-backends-infra--last-scrollback-inject-time now)
(setq result (replace-regexp-in-string
ai-code-backends-infra--screen-clear-regexp
scroll-seq result t t)))
;; 3. Strip ED 3 (clear scrollback).
(setq result (replace-regexp-in-string
ai-code-backends-infra--scrollback-clear-regexp "" result))
;; 4. Convert cursor-home + erase-to-end (\e[H\e[J) into a
;; scrollback-preserving sequence, throttled to avoid flooding
;; the scrollback ring with repeated TUI frames (e.g. the
;; Claude Code badge appearing on every redraw).
(when inject-ok
(let ((home-erase-re "\033\\[H\033\\[J\\|\033\\[H\033\\[0J"))
(when (string-match-p home-erase-re result)
(setq ai-code-backends-infra--last-scrollback-inject-time now)
(setq result (replace-regexp-in-string
home-erase-re
(concat scroll-seq "\033[H") result t t)))))
;; 5. Detect synchronized-update frames (\e[?2026h followed by
;; cursor to row 1) and inject scrollback preservation before
;; the frame redraw, throttled to avoid flooding the scrollback
;; ring. Only trigger on chunks > 500 bytes (meaningful
;; redraws, not small cursor movements). Guarded by the
;; buffer-local `--sync-redraw-scrollback' flag so that only
;; backends that explicitly opt in (e.g. Copilot CLI) get
;; the injection.
(when (and ai-code-backends-infra--sync-redraw-scrollback
(> (length result) 500)
(string-match ai-code-backends-infra--sync-redraw-regexp result)
(>= (- (float-time)
ai-code-backends-infra--last-scrollback-inject-time)
ai-code-backends-infra-scrollback-inject-interval))
(setq ai-code-backends-infra--last-scrollback-inject-time (float-time))
(let ((match-start (match-beginning 0)))
(setq result (concat (substring result 0 match-start)
scroll-seq
(substring result match-start)))))
result)
str))
(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--configure-session-input-shortcuts ()
"Install session-local shortcuts for prompt-oriented terminal input."
(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)))
;;; Terminal Backend Abstraction
(defun ai-code-backends-infra--terminal-ensure-backend ()
"Ensure the selected terminal backend is available."
(pcase ai-code-backends-infra-terminal-backend
('vterm (ai-code-backends-infra-vterm-ensure-backend))
('eat (ai-code-backends-infra-eat-ensure-backend))
('ghostel (ai-code-backends-infra-ghostel-ensure-backend))
(_ (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 (ai-code-backends-infra-vterm-navigation-mode-p))
('eat (ai-code-backends-infra-eat-navigation-mode-p))
('ghostel (ai-code-backends-infra-ghostel-navigation-mode-p))
(_ 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 (ai-code-backends-infra-vterm-install-navigation-cursor-sync))
('ghostel (ai-code-backends-infra-ghostel-install-navigation-cursor-sync))
('eat (ai-code-backends-infra-eat-install-navigation-cursor-sync))))
(defun ai-code-backends-infra--terminal-send-string (string)
"Send STRING to the terminal in the current buffer."
(pcase (ai-code-backends-infra--current-terminal-backend)
('vterm (ai-code-backends-infra-vterm-send-string string))
('eat (ai-code-backends-infra-eat-send-string string))
('ghostel (ai-code-backends-infra-ghostel-send-string string))
(_ (error "Unknown terminal backend: %s"
(ai-code-backends-infra--current-terminal-backend)))))
(defun ai-code-backends-infra--terminal-send-escape ()
"Send escape key to the terminal in the current buffer."
(pcase (ai-code-backends-infra--current-terminal-backend)
('vterm (ai-code-backends-infra-vterm-send-escape))
('eat (ai-code-backends-infra-eat-send-escape))
('ghostel (ai-code-backends-infra-ghostel-send-escape))
(_ (error "Unknown terminal backend: %s"
(ai-code-backends-infra--current-terminal-backend)))))
(defun ai-code-backends-infra--terminal-send-return ()
"Send return key to the terminal in the current buffer."
(pcase (ai-code-backends-infra--current-terminal-backend)
('vterm (ai-code-backends-infra-vterm-send-return))
('eat (ai-code-backends-infra-eat-send-return))
('ghostel (ai-code-backends-infra-ghostel-send-return))
(_ (error "Unknown terminal backend: %s"
(ai-code-backends-infra--current-terminal-backend)))))
(defun ai-code-backends-infra--terminal-send-backspace ()
"Send backspace key to the terminal in the current buffer."
(pcase (ai-code-backends-infra--current-terminal-backend)
('vterm (ai-code-backends-infra-vterm-send-backspace))
('eat (ai-code-backends-infra-eat-send-backspace))
('ghostel (ai-code-backends-infra-ghostel-send-backspace))
(_ (error "Unknown terminal backend: %s"
(ai-code-backends-infra--current-terminal-backend)))))
(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)
(ai-code-session-link--linkify-session-region (point-min) (point-max))))
;;; 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 (ai-code-backends-infra-vterm-resize-handler))
('eat (ai-code-backends-infra-eat-resize-handler))
('ghostel (ai-code-backends-infra-ghostel-resize-handler))
(_ (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.
Suppress reflow when terminal width is unchanged or when the session
buffer is in scroll/copy mode, working around bug #1422."
(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)))))
(cond
;; Not in a session buffer - pass through
((not (ai-code-backends-infra--session-buffer-p (current-buffer)))
base-result)
;; In scroll/copy mode - suppress reflow to avoid disrupting navigation
((ai-code-backends-infra--terminal-navigation-mode-p)
nil)
;; Width changed - allow reflow
((not dimensions-stable)
base-result)
;; Height-only change with reflow glitch prevention - suppress
(ai-code-backends-infra-prevent-reflow-glitch
nil)
;; Default - pass through
(t 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
(memq ai-code-backends-infra-terminal-backend '(vterm eat)))))
(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--fit-side-window-body-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))
;; Sync terminal dimensions with the actual window size.
;; The buffer may have been created with different dimensions before
;; being displayed in this window.
(when (and window (buffer-live-p buffer))
(ai-code-backends-infra--sync-terminal-dimensions buffer window))
window))
(defun ai-code-backends-infra--fit-side-window-body-width (window)
"Resize WINDOW so its body width matches `ai-code-backends-infra-window-width'."
(let ((delta (- ai-code-backends-infra-window-width
(window-body-width window))))
(unless (zerop delta)
(window-resize window delta t))))
(defun ai-code-backends-infra--sync-terminal-dimensions (buffer window)
"Sync terminal dimensions in BUFFER to match WINDOW size.
This ensures the terminal process has the correct dimensions after
the buffer has been displayed in its final window, which may differ
from the window where it was initially created."
(when (and buffer window (buffer-live-p buffer) (window-live-p window))
(with-current-buffer buffer
(when-let ((proc (get-buffer-process buffer)))
(let ((height (window-body-height window))
(width (window-body-width window)))
(set-process-window-size proc height width))))))
;;; 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 and SOURCE-BUFFER.
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)
(or (ai-code-backends-infra--parse-session-buffer-name
(buffer-name attached)
prefix)
(ai-code-backends-infra--session-buffer-matches-directory-p
attached
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-session-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-session-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--session-buffer-matches-directory-p (buffer directory)
"Return non-nil when BUFFER is live and still belongs to DIRECTORY."
(and (buffer-live-p buffer)
(when-let ((buffer-directory
(ai-code-backends-infra--buffer-session-directory buffer)))
(string=
(ai-code-backends-infra--normalize-session-directory buffer-directory)
(ai-code-backends-infra--normalize-session-directory directory)))))
(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* ((remembered (gethash (ai-code-backends-infra--session-map-key prefix directory)
ai-code-backends-infra--directory-buffer-map))
(buffers (ai-code-backends-infra--find-session-buffers prefix directory)))
(when (and remembered
(ai-code-backends-infra--session-buffer-matches-directory-p
remembered
directory)
(not (memq remembered buffers)))
(push remembered buffers))
(cond
((null buffers) nil)
((= (length buffers) 1)
(ai-code-backends-infra--remember-session-buffer prefix directory (car buffers))
(car buffers))
(t
(let* ((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 (delq nil
(mapcar (lambda (buf)
(when-let ((instance
(ai-code-backends-infra--session-instance-name
(buffer-name buf)
prefix)))
(cons instance 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--resolve-session-target (working-dir buffer-name
prefix instance-name
force-prompt)
"Return resolved session target information.
WORKING-DIR is the session directory.
BUFFER-NAME is the explicit terminal buffer name, when provided.
PREFIX enables instance-based naming.
INSTANCE-NAME overrides interactive instance selection when non-nil.
FORCE-PROMPT forces instance prompting when PREFIX is non-nil.
Return a plist with :instance-name, :buffer-name, and :session-key."
(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)))))
(list :instance-name resolved-instance
:buffer-name resolved-buffer-name
:session-key (ai-code-backends-infra--session-key
working-dir
resolved-instance))))
(defun ai-code-backends-infra--resolve-session-context (working-dir buffer-name
process-table
prefix instance-name
force-prompt)
"Return resolved session context for session lifecycle operations.
WORKING-DIR is the session directory.
BUFFER-NAME is the explicit terminal buffer name, when provided.
PROCESS-TABLE maps session keys to processes.
PREFIX enables instance-based naming.
INSTANCE-NAME overrides interactive instance selection when non-nil.
FORCE-PROMPT forces instance prompting when PREFIX is non-nil.
Return a plist with target information plus the current buffer and process."
(let* ((session-target (ai-code-backends-infra--resolve-session-target
working-dir
buffer-name
prefix
instance-name
force-prompt))
(session-key (plist-get session-target :session-key))
(resolved-buffer-name (plist-get session-target :buffer-name)))
(append session-target
(list :buffer (get-buffer resolved-buffer-name)
:existing-process (gethash session-key process-table)))))
(defun ai-code-backends-infra--reuse-session-window (buffer working-dir
prefix multiline-input-sequence)
"Toggle visibility for an existing session BUFFER.
When BUFFER is already visible, close its window.
Otherwise refresh session-local state and display it."
(if (get-buffer-window buffer)
(delete-window (get-buffer-window buffer))
(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)))
(defun ai-code-backends-infra--finalize-started-session (buffer process
working-dir buffer-name
process-table resolved-instance
prefix escape-fn cleanup-fn
multiline-input-sequence
post-start-fn)
"Finalize a successfully started session BUFFER and PROCESS."
(set-process-sentinel
process
(lambda (_proc event)
(ai-code-backends-infra--cleanup-session
working-dir
buffer-name
process-table
resolved-instance
prefix
event)
(when cleanup-fn
(funcall cleanup-fn))))
(ai-code-backends-infra--configure-session-buffer
buffer escape-fn multiline-input-sequence)
(when post-start-fn
(funcall post-start-fn buffer process resolved-instance))
(with-current-buffer 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 buffer)
(ai-code-backends-infra--display-buffer-in-side-window buffer))