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
|
;;; ai-vterm.el --- In-Emacs AI-agent launcher with vertical-split vterm -*- lexical-binding: t; -*-
;; Author: Craig Jennings <c@cjennings.net>
;;; Commentary:
;;
;; Layer: 3 (Domain Workflow).
;; Category: D.
;; Load shape: eager.
;; Eager reason: registers four global keys for the AI-agent vterm launcher; a
;; command-loaded deferral candidate.
;; Top-level side effects: four global key bindings.
;; Runtime requires: cl-lib, seq, cj-window-geometry-lib, cj-window-toggle-lib,
;; host-environment.
;; Direct test load: yes.
;;
;; Picks an AI-agent project (a dir under ~/.emacs.d, ~/code/*, or
;; ~/projects/* containing .ai/protocols.org), opens or reuses a vterm
;; buffer named "agent [<basename>]", sends the agent's startup
;; instruction to it, and routes the buffer to a side window via
;; display-buffer-alist. When the frame already has a window forming the
;; half the agent would occupy (a right column on a desktop, a bottom row
;; on a laptop), the agent reuses that slot rather than splitting a third
;; window in; toggling off restores the displaced buffer to the slot.
;; Otherwise placement is a host-aware split: a right-side split at 50%
;; width on a desktop, a bottom split at 75% height on a laptop (see
;; `cj/--ai-vterm-default-direction'). Multiple
;; projects produce multiple coexisting buffers that share the same
;; slot; switching among them is a buffer-switch, not a
;; kill-and-recreate.
;;
;; Each project's agent runs inside a tmux session named
;; "<cj/ai-vterm-tmux-session-prefix><basename>" (default prefix "aiv-").
;; The prefix lets `tmux ls' be filtered to AI-vterm's own sessions, so
;; after an Emacs crash the project picker can match surviving sessions
;; back to their directories: matched projects sort to the top of the
;; picker (flagged "[detached]" -- session alive, no Emacs buffer -- or
;; "[running]" when a live vterm buffer exists), the rest follow in
;; alphabetical order.
;;
;; Four F-key entry points:
;;
;; - F9 `cj/ai-vterm' -- DWIM dispatch. If an agent buffer is
;; currently displayed in this frame, F9 toggles it off: when it
;; took over an existing window (a reused slot) the buffer it
;; displaced returns to that slot, when it was split into its own
;; window that window is removed, and when it fills the frame it
;; is buried. Otherwise, if exactly one agent buffer is alive,
;; F9 re-displays it; if zero or two-plus are alive, F9 falls
;; through to the project picker.
;; - C-F9 `cj/ai-vterm-pick-project' -- always show the project
;; picker, even when an agent buffer is currently displayed.
;; Used when the user wants to start a new project session
;; instead of toggling the current one.
;; - M-F9 `cj/ai-vterm-close' -- gracefully close an agent: kill its
;; tmux session (stopping the agent process), then its vterm
;; buffer and window. Confirms first. Targets the current
;; agent, the sole live agent, or prompts among several.
;; - C-S-F9 `cj/ai-vterm-close' -- same close command, second binding.
;; (M-F9 is the primary; C-S-F9 may be swallowed by the
;; Wayland/PGTK layer on some machines.)
;;
;; Existing windmove (Shift-arrows) handles code <-> agent focus
;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither
;; needs anything new from this module.
;;; Code:
(require 'cl-lib)
(require 'seq)
(require 'cj-window-geometry-lib)
(require 'cj-window-toggle-lib)
(require 'host-environment)
(declare-function vterm "vterm" (&optional buffer-name))
(declare-function vterm-send-string "vterm" (string &optional paste-p))
(declare-function vterm-send-return "vterm" ())
(defvar vterm-mode-map)
(defgroup ai-vterm nil
"In-Emacs AI-agent launcher with vertical-split vterm."
:group 'tools)
(defcustom cj/ai-vterm-agent-command
"claude \"Read .ai/protocols.org and follow all instructions.\""
"Shell command sent to a fresh AI-vterm to start the agent.
The default invokes the Claude Code CLI; set it to whatever terminal
agent you run (aider, an open-source LLM TUI, etc.)."
:type 'string
:group 'ai-vterm)
(defvar cj/--ai-vterm-suppress-tmux nil
"When non-nil, the generic vterm tmux-launch hook skips its auto-tmux step.
ai-vterm dynamically binds this around `(vterm)' so the hook in
vterm-config.el doesn't send a bare \"tmux\\n\" before the named
session launch command runs. The hook reads the variable via
`bound-and-true-p' so loading order between the two modules doesn't
matter.")
(defcustom cj/ai-vterm-project-roots
(list (expand-file-name "~/.emacs.d"))
"Directories that are themselves AI-agent projects.
Each entry is included as a candidate when it exists and contains
.ai/protocols.org. Use this for single-project roots like ~/.emacs.d."
:type '(repeat directory)
:group 'ai-vterm)
(defcustom cj/ai-vterm-container-roots
(list (expand-file-name "~/code")
(expand-file-name "~/projects"))
"Directories whose immediate children are scanned for agent projects.
Each entry's child directories are included as candidates when they
contain .ai/protocols.org. Use this for container dirs like ~/code."
:type '(repeat directory)
:group 'ai-vterm)
(defcustom cj/ai-vterm-tmux-session-prefix "aiv-"
"Prefix prepended to tmux session names AI-vterm creates.
The session name for a project is this prefix followed by the
project's basename (whitespace collapsed to hyphens). The prefix
lets `tmux ls' output be filtered down to AI-vterm's own sessions --
so after an Emacs crash the project picker can match surviving
sessions back to their directories and surface them first. Pick
something unlikely to collide with hand-rolled tmux sessions; the
default \"aiv-\" is short for \"ai-vterm\"."
:type 'string
:group 'ai-vterm)
(defcustom cj/ai-vterm-tmux-window-name "ai"
"Name given to the first tmux window in an AI-vterm session.
Passed as `tmux new-session -n', so the window running the AI tool
shows up as this name in `tmux ls' / the status line. A later
window opened by hand (e.g. a shell) auto-names after its command,
so the two read distinctly instead of both showing up as the
running program."
:type 'string
:group 'ai-vterm)
(defconst cj/--ai-vterm-name-prefix "agent ["
"Buffer-name prefix shared by all AI-vterm buffers.
Single source of truth for both buffer construction in
`cj/--ai-vterm-buffer-name' and detection in
`cj/--ai-vterm-buffer-p'. The display-buffer-alist rule keys on the
escaped form \"\\\\`agent \\\\[\" -- they must stay in sync.")
(defun cj/--ai-vterm-buffer-name (dir)
"Return the AI-vterm buffer name for project directory DIR.
The name pattern is \"agent [<basename>]\". The display-buffer-alist
rule keys on the literal prefix \"agent [\", so changing the format
breaks routing to the right-side window."
(format "%s%s]"
cj/--ai-vterm-name-prefix
(file-name-nondirectory (directory-file-name dir))))
(defun cj/--ai-vterm-buffer-p (buffer)
"Return non-nil when BUFFER is an AI-vterm buffer.
A buffer qualifies when its name starts with the literal prefix in
`cj/--ai-vterm-name-prefix' (\"agent [\"). The check is anchored at
the start so names like \"foo agent [bar]\" do not match."
(and (bufferp buffer)
(buffer-live-p buffer)
(string-prefix-p cj/--ai-vterm-name-prefix (buffer-name buffer))))
(defun cj/--ai-vterm-agent-buffers ()
"Return the live AI-vterm buffers in `buffer-list' order.
Order matches `buffer-list' on the selected frame, which is most-
recently-selected first. Non-AI-vterm buffers are filtered out via
`cj/--ai-vterm-buffer-p'."
(seq-filter #'cj/--ai-vterm-buffer-p (buffer-list)))
(defun cj/--ai-vterm-displayed-agent-window (&optional frame)
"Return a window in FRAME currently displaying an AI-vterm buffer, or nil.
FRAME defaults to the selected frame. When more than one window in
the frame shows an agent buffer, the first one in `window-list' order
is returned. The minibuffer is excluded from the search."
(seq-find (lambda (w)
(cj/--ai-vterm-buffer-p (window-buffer w)))
(window-list (or frame (selected-frame)) 'never)))
(defun cj/--ai-vterm-tmux-session-name (dir)
"Return the tmux session name for project directory DIR.
`cj/ai-vterm-tmux-session-prefix' followed by DIR's basename, sanitized
to a form tmux won't re-mangle: runs of whitespace become a single
hyphen, and `.' / `:' become `_'. tmux disallows `.' and `:' in
session names and silently rewrites them to `_', so a project like
`.emacs.d' really runs in session `aiv-_emacs_d', not `aiv-.emacs.d' --
sanitizing up front keeps the computed name matching the live one (and
keeps `cj/--ai-vterm-session-active-p' and the crash-recovery picker
from missing such projects). The prefix lets `tmux ls' output be
filtered to AI-vterm's own sessions (see
`cj/--ai-vterm-live-tmux-sessions')."
(concat cj/ai-vterm-tmux-session-prefix
(replace-regexp-in-string
"[.:]" "_"
(replace-regexp-in-string
"[[:space:]]+" "-"
(file-name-nondirectory (directory-file-name dir))))))
(defun cj/--ai-vterm-live-tmux-sessions ()
"Return live tmux session names that carry the AI-vterm prefix.
Runs `tmux list-sessions'. Returns the names beginning with
`cj/ai-vterm-tmux-session-prefix', or nil when tmux is not installed,
no server is running, or the command exits non-zero -- the picker
treats nil as \"no sessions to surface\" and falls back to a plain
alphabetical list."
(let* ((prefix cj/ai-vterm-tmux-session-prefix)
(exit nil)
(output (with-temp-buffer
(setq exit (condition-case nil
(process-file "tmux" nil '(t nil) nil
"list-sessions" "-F"
"#{session_name}")
(error nil)))
(buffer-string))))
(when (and (integerp exit) (zerop exit))
(seq-filter (lambda (name) (string-prefix-p prefix name))
(split-string output "\n" t)))))
(defun cj/--ai-vterm-session-active-p (dir sessions)
"Return non-nil when DIR's tmux session name is in SESSIONS.
SESSIONS is the list from `cj/--ai-vterm-live-tmux-sessions' (or nil).
The match is forward: DIR's expected session name is computed and
looked up in SESSIONS, so the lossy whitespace->hyphen transform in
`cj/--ai-vterm-tmux-session-name' never needs reversing."
(and (member (cj/--ai-vterm-tmux-session-name dir) sessions) t))
(defun cj/--ai-vterm-launch-command (dir)
"Return the shell command line that runs the AI tool in a project tmux session.
Uses `tmux new-session -A' so a second F9 on the same project reattaches
to the running session instead of spawning a new one. The session name
comes from `cj/--ai-vterm-tmux-session-name'; the first window is named
`cj/ai-vterm-tmux-window-name' (default \"ai\") so a later hand-opened
window auto-names after its command and the two read distinctly.
The shell command run on first creation is
<cj/ai-vterm-agent-command>; exec bash
so the tmux window survives the AI command exiting -- the session stays
alive with a bare bash prompt for recovery, and reattach works the same way."
(let ((session (cj/--ai-vterm-tmux-session-name dir))
(start-dir (expand-file-name dir)))
;; Pass the inner shell-command-string through `shell-quote-argument'
;; so any single quotes embedded in a user-customized
;; `cj/ai-vterm-agent-command' don't break the literal single-quote
;; wrap below. The default value carries embedded double quotes
;; (\"Read .ai/protocols.org and follow all instructions.\") which
;; was safe in the prior shape but a single-quoted custom value
;; silently broke the shell parse.
(format "tmux new-session -A -s %s -n %s -c %s %s"
(shell-quote-argument session)
(shell-quote-argument cj/ai-vterm-tmux-window-name)
(shell-quote-argument start-dir)
(shell-quote-argument
(concat cj/ai-vterm-agent-command "; exec bash")))))
(defun cj/--ai-vterm-has-marker-p (dir)
"Return non-nil when DIR contains .ai/protocols.org."
(file-exists-p (expand-file-name ".ai/protocols.org" dir)))
(defun cj/--ai-vterm-candidates ()
"Return the list of AI-agent project paths.
Each entry of `cj/ai-vterm-project-roots' contributes itself when it
exists and contains .ai/protocols.org. Each entry of
`cj/ai-vterm-container-roots' contributes its immediate child
directories that contain .ai/protocols.org.
Returns absolute paths. Nonexistent roots are skipped silently."
(let (result)
(dolist (root cj/ai-vterm-project-roots)
(let ((expanded (expand-file-name root)))
(when (and (file-directory-p expanded)
(cj/--ai-vterm-has-marker-p expanded))
(push expanded result))))
(dolist (root cj/ai-vterm-container-roots)
(let ((expanded (expand-file-name root)))
(when (file-directory-p expanded)
(dolist (child (directory-files
expanded t directory-files-no-dot-files-regexp t))
(when (and (file-directory-p child)
(cj/--ai-vterm-has-marker-p child))
(push child result))))))
(nreverse result)))
(defvar cj/--ai-vterm-mru nil
"Project dirs opened via the AI-vterm launcher this session, newest first.
Maintained by `cj/--ai-vterm-record-mru' (called from
`cj/--ai-vterm-show-or-create') and consumed by
`cj/--ai-vterm-sort-candidates' so the project picker puts
recently-opened projects at the top of the active-sessions group.
In-memory only -- not persisted across Emacs restarts.")
(defun cj/--ai-vterm-record-mru (dir)
"Move DIR to the front of `cj/--ai-vterm-mru'.
DIR is normalized with `expand-file-name' + `directory-file-name' so a
trailing slash or `~' form doesn't create a duplicate entry; any prior
occurrence is removed first, keeping the list a true MRU order."
(let ((d (directory-file-name (expand-file-name dir))))
(setq cj/--ai-vterm-mru (cons d (delete d cj/--ai-vterm-mru)))))
(defun cj/--ai-vterm-mru-rank (dir)
"Return DIR's index in `cj/--ai-vterm-mru', or nil when it isn't there.
DIR is normalized the same way `cj/--ai-vterm-record-mru' stores
entries, so a trailing slash doesn't defeat the lookup."
(seq-position cj/--ai-vterm-mru
(directory-file-name (expand-file-name dir))))
(defun cj/--ai-vterm-sort-candidates (dirs sessions)
"Order DIRS for the project picker.
DIRS with a live tmux session in SESSIONS (per
`cj/--ai-vterm-session-active-p') come first, ordered most-recently-
opened first (per `cj/--ai-vterm-mru'); active dirs not opened yet this
session fall after them, alphabetical by abbreviated path. DIRS with no
session follow, always alphabetical. SESSIONS nil means nothing is
active, so the result is a plain alphabetical list; an empty MRU makes
the active group alphabetical too."
(let* ((alpha (lambda (a b)
(string< (abbreviate-file-name a) (abbreviate-file-name b))))
(mru-then-alpha
(lambda (a b)
(let ((ra (cj/--ai-vterm-mru-rank a))
(rb (cj/--ai-vterm-mru-rank b)))
(cond ((and ra rb) (< ra rb))
(ra t)
(rb nil)
(t (funcall alpha a b))))))
(active-p (lambda (d) (cj/--ai-vterm-session-active-p d sessions)))
(active (seq-filter active-p dirs))
(inactive (seq-remove active-p dirs)))
(append (sort active mru-then-alpha) (sort inactive alpha))))
(defun cj/--ai-vterm-process-live-p (buffer)
"Return non-nil when BUFFER has a live process attached."
(let ((proc (get-buffer-process buffer)))
(and proc (process-live-p proc))))
(defcustom cj/ai-vterm-desktop-width 0.5
"Default fraction of frame width for the AI-vterm window on a desktop.
On a desktop the agent opens as a right-side vertical split (see
`cj/--ai-vterm-default-direction'), so this fraction is interpreted
as a window width. Used by `cj/--ai-vterm-default-size' as the size
fallback when `cj/--ai-vterm-last-size' is nil (i.e. the user hasn't
yet toggled off an agent window in this session)."
:type 'number
:group 'ai-vterm)
(defcustom cj/ai-vterm-laptop-height 0.75
"Default fraction of frame height for the AI-vterm window on a laptop.
On a laptop the agent opens as a bottom horizontal split (see
`cj/--ai-vterm-default-direction'), so this fraction is interpreted
as a window height. Used by `cj/--ai-vterm-default-size' as the size
fallback when `cj/--ai-vterm-last-size' is nil."
:type 'number
:group 'ai-vterm)
(defun cj/--ai-vterm-default-direction ()
"Return the host-appropriate default split direction for the agent window.
`below' on a laptop (bottom horizontal split), `right' on a desktop
(right-side vertical split). Detected via `env-laptop-p'."
(if (env-laptop-p) 'below 'right))
(defun cj/--ai-vterm-default-size ()
"Return the host-appropriate default size fraction for the agent window.
`cj/ai-vterm-laptop-height' on a laptop, `cj/ai-vterm-desktop-width'
on a desktop -- pairing with the axis chosen by
`cj/--ai-vterm-default-direction'."
(if (env-laptop-p)
cj/ai-vterm-laptop-height
cj/ai-vterm-desktop-width))
(defvar cj/--ai-vterm-last-direction nil
"Last user-chosen direction for the AI-vterm display.
Symbol: right, below, or left. `above' is never stored -- the agent
window must not be remembered at the top of the frame, so a top
placement falls back to the host default at capture time. nil means no
agent window has been toggled off yet this session, so the default
direction applies. Captured at toggle-off by
`cj/--ai-vterm-capture-state' and consumed by
`cj/--ai-vterm-display-saved'.")
(defvar cj/--ai-vterm-last-was-bury nil
"Non-nil when the last F9 toggle-off used `bury-buffer'.
Set by `cj/ai-vterm' in its `toggle-off' branch: t when the agent
window was the only window in the frame (so toggle-off buried
without deleting), nil when the window was deleted. Consumed by
`cj/--ai-vterm-display-saved' to decide between restoring the
buried agent in the current window (the only one) or splitting per
the saved direction.")
(defvar cj/--ai-vterm-last-size nil
"Last user-chosen body size for the AI-vterm display.
Positive integer: body-columns when `cj/--ai-vterm-last-direction'
is right or left, body-lines when below or above. nil means use
the host-aware default from `cj/--ai-vterm-default-size' (a float
fraction).
Body size, not total size, because total-width includes the
right-edge divider when the window has a right sibling but excludes
it when the window is at the frame edge. Capturing total-width
from a rightmost agent (no divider) and replaying into a middle
position (with divider) leaves the body 1 column short -- visible
as 1 col of the sibling buffer peeking through where agent should
have ended. Body-width is divider-independent and matches what the
user actually sees.
Absolute values rather than fractions because
`display-buffer-in-direction' interprets a float `window-width' /
`window-height' as a fraction of the new window's parent in the
window tree. In a 3+ window layout the parent may be a sub-tree,
and a fraction-of-frame produces the wrong size on replay
(squeezes the other windows). An integer is unambiguous, at the
cost of not auto-scaling if the frame itself resizes.")
(defun cj/--ai-vterm-capture-state (window)
"Capture WINDOW's direction and size into module-level state.
Sets `cj/--ai-vterm-last-direction' and `cj/--ai-vterm-last-size'
so a subsequent F9 display can restore the user's chosen orientation
and size. Called at toggle-off (just before the window is torn
down). The default direction is host-aware via
`cj/--ai-vterm-default-direction' (used only when WINDOW fills its
frame and no direction can be inferred). Does nothing when WINDOW
is not live."
(cj/window-toggle-capture-state
window (cj/--ai-vterm-default-direction)
'cj/--ai-vterm-last-direction
'cj/--ai-vterm-last-size
'(right below left)))
(defun cj/--ai-vterm-reuse-existing-agent (buffer _alist)
"Display-buffer action: reuse any window in this frame already showing
an agent buffer.
Looks up `cj/--ai-vterm-displayed-agent-window' on the selected
frame. When an agent window exists, replaces its buffer with BUFFER
and returns the window. When none exists, returns nil so the next
action in the chain runs.
This is more specific than `display-buffer-use-some-window', which
would happily steal any non-selected window (e.g. a code window
above the agent split) when the user is focused in agent and
swaps projects via C-F9. The selective lookup here keeps non-agent
windows undisturbed and preserves the user's split geometry across
project changes."
(let ((win (cj/--ai-vterm-displayed-agent-window)))
(when win
(set-window-buffer win buffer)
win)))
(defun cj/--ai-vterm-reuse-edge-window (buffer _alist)
"Display-buffer action: reuse the existing window forming the target half.
When the frame already holds a window forming the half the agent would
occupy -- the right column on a desktop, the bottom row on a laptop, per
the saved or default direction -- swap BUFFER into it with
`set-window-buffer' and return that window, rather than splitting a third
window in. The target half is found by `cj/window-at-edge'.
Returns nil when there is no such half to reuse (a single-window frame,
or a layout split on the other axis), so the chain falls through to
`cj/--ai-vterm-display-saved', which splits a fresh half. Also returns
nil when the edge window is dedicated -- those are not ours to replace.
Records the displaced buffer through `display-buffer-record-window'
\(type `reuse') before swapping, so the native `quit-restore-window'
called at toggle-off puts that buffer back into the slot instead of
deleting the window -- toggling swaps the slot's buffer between the
displaced buffer and the agent, never changing the window count.
Runs after `cj/--ai-vterm-reuse-existing-agent', so an agent already on
screen has been handled already; the window reused here always holds a
non-agent buffer, which is replaced (it stays alive, just unshown)."
(let* ((direction (or cj/--ai-vterm-last-direction
(cj/--ai-vterm-default-direction)))
(win (cj/window-at-edge direction)))
(when (and win (not (window-dedicated-p win)))
(display-buffer-record-window 'reuse win buffer)
(set-window-buffer win buffer)
win)))
(defun cj/--ai-vterm-display-saved (buffer alist)
"Display-buffer action: split per saved direction and size.
When the prior toggle-off was a bury (single-window state, flagged
via `cj/--ai-vterm-last-was-bury') and the frame is still single-
window, restore the agent into the selected window in place rather
than splitting -- preserves the user's lone-window layout across
F9 toggles.
Otherwise delegates to `cj/window-toggle-display-saved' against the
F9 state vars, falling back to the host-aware defaults from
`cj/--ai-vterm-default-direction' and `cj/--ai-vterm-default-size'."
(cond
((and cj/--ai-vterm-last-was-bury (one-window-p))
(setq cj/--ai-vterm-last-was-bury nil)
(let ((win (selected-window)))
(set-window-buffer win buffer)
win))
(t
(setq cj/--ai-vterm-last-was-bury nil)
(cj/window-toggle-display-saved
buffer alist
'cj/--ai-vterm-last-direction (cj/--ai-vterm-default-direction)
'cj/--ai-vterm-last-size (cj/--ai-vterm-default-size)))))
(defun cj/--ai-vterm-display-rule-list ()
"Return the `display-buffer-alist' entry list installed by this module.
The single rule routes any buffer whose name starts with \"agent [\"
through four actions in order:
1. `display-buffer-reuse-window' -- if the same buffer is already
visible in any window, focus that one.
2. `cj/--ai-vterm-reuse-existing-agent' -- otherwise, if any
window in this frame already shows an agent-prefixed buffer,
swap its buffer for the new one (preserves geometry across
project changes via C-F9).
3. `cj/--ai-vterm-reuse-edge-window' -- otherwise, if the frame
already has a window forming the half the agent would occupy
(the right column on a desktop, the bottom row on a laptop),
reuse it instead of splitting a third window in.
4. `cj/--ai-vterm-display-saved' -- otherwise (single-window frame,
or a layout split on the other axis), split per the saved
direction + size from the last toggle-off (or defaults when no
capture has happened this session).
`display-buffer-in-side-window' is avoided deliberately. Side
windows enforce dedication, which breaks `buffer-move' (C-M-arrows)
and `switch-to-buffer' replacement. The chain above keeps the
resulting window an ordinary window so all standard window commands
work.
`display-buffer-use-some-window' is also avoided -- it would happily
steal any non-selected window (e.g. a code window above an agent
split) when the user is focused in agent and switches projects."
'(("\\`agent \\["
(display-buffer-reuse-window
cj/--ai-vterm-reuse-existing-agent
cj/--ai-vterm-reuse-edge-window
cj/--ai-vterm-display-saved)
(inhibit-same-window . t))))
(dolist (entry (cj/--ai-vterm-display-rule-list))
(add-to-list 'display-buffer-alist entry))
(defun cj/--ai-vterm-show-or-create (dir name)
"Show or create the AI-vterm buffer for project DIR with buffer NAME.
If a buffer named NAME exists with a live process, display it. If
the buffer exists but its process is dead, kill it and recreate. If
no such buffer exists, create a new vterm in DIR and send the
project's tmux launch command (see `cj/--ai-vterm-launch-command') so
the same project basename reattaches across Emacs restarts.
The dynamic binding of `cj/--ai-vterm-suppress-tmux' around `(vterm)'
suppresses the generic tmux-launch hook in vterm-config.el so
it doesn't fire a bare \"tmux\\n\" before the project-named launch
command runs.
Records DIR in `cj/--ai-vterm-mru' (whichever branch runs) so the
project picker can list recently-opened projects first. Returns the
buffer."
(cj/--ai-vterm-record-mru dir)
(let ((existing (get-buffer name)))
(cond
((and existing (cj/--ai-vterm-process-live-p existing))
(display-buffer existing)
existing)
(t
(when existing
(kill-buffer existing))
;; `vterm' calls pop-to-buffer-same-window internally, which
;; replaces the selected window's buffer (e.g. the dashboard at
;; fresh startup) before our display-buffer-alist rule has a
;; chance to route it. `save-window-excursion' reverts that
;; side-effect; the explicit display-buffer call below then
;; routes the buffer through the alist into a right-side split.
(save-window-excursion
(let ((default-directory dir)
(cj/--ai-vterm-suppress-tmux t))
(vterm name)))
(let ((buf (get-buffer name)))
(with-current-buffer buf
(vterm-send-string (cj/--ai-vterm-launch-command dir))
(vterm-send-return))
(display-buffer buf)
buf)))))
(defun cj/--ai-vterm-format-candidate (path &optional sessions)
"Return the display name for PATH in the AI-vterm project picker.
Appends \" [running]\" when the project's agent buffer exists with
a live process; otherwise \" [detached]\" when PATH's tmux session
name is in SESSIONS (a session that survived an Emacs crash, no
buffer yet); otherwise just the abbreviated path. Path is
abbreviated via `abbreviate-file-name' so it reads as ~/code/foo
rather than the full home-dir form."
(let* ((name (cj/--ai-vterm-buffer-name path))
(buf (get-buffer name))
(running (and buf (cj/--ai-vterm-process-live-p buf)))
(detached (and (not running)
(cj/--ai-vterm-session-active-p path sessions)))
(display-path (abbreviate-file-name path)))
(cond
(running (format "%s [running]" display-path))
(detached (format "%s [detached]" display-path))
(t display-path))))
(defun cj/--ai-vterm-completion-table (alist)
"Return a `completing-read' table over ALIST that pins candidate order.
`completing-read' over a bare alist lets the front-end (Vertico)
re-sort candidates by recency / length / alpha, which would defeat
the picker's active-sessions-first grouping. Returning
`display-sort-function' and `cycle-sort-function' of `identity' in
the metadata keeps the order ALIST was built in."
(lambda (string predicate action)
(if (eq action 'metadata)
'(metadata (display-sort-function . identity)
(cycle-sort-function . identity))
(complete-with-action action alist string predicate))))
(defun cj/--ai-vterm-pick-project ()
"Prompt for an AI-agent project; return its absolute path.
Candidates come from `cj/--ai-vterm-candidates', ordered by
`cj/--ai-vterm-sort-candidates' so projects with a live tmux session
appear first (then alphabetical by abbreviated path). Display uses
`cj/--ai-vterm-format-candidate', which abbreviates the path and
flags a live session via \" [running]\" (an Emacs vterm buffer is
alive) or \" [detached]\" (the tmux session survived, no buffer).
Signals `user-error' when no candidates exist."
(let ((candidates (cj/--ai-vterm-candidates)))
(unless candidates
(user-error "No AI-agent projects found under %s"
(mapconcat #'identity
(append cj/ai-vterm-project-roots
cj/ai-vterm-container-roots)
", ")))
(let* ((sessions (cj/--ai-vterm-live-tmux-sessions))
(sorted (cj/--ai-vterm-sort-candidates candidates sessions))
(display-alist
(mapcar (lambda (p)
(cons (cj/--ai-vterm-format-candidate p sessions) p))
sorted))
(chosen (completing-read
"AI vterm project: "
(cj/--ai-vterm-completion-table display-alist)
nil t)))
(or (cdr (assoc chosen display-alist))
(expand-file-name chosen)))))
(defun cj/--ai-vterm-dispatch ()
"Compute the F9 (`cj/ai-vterm') action without performing it.
Returns one of:
- (toggle-off . WINDOW) -- agent is displayed in WINDOW; quit it.
- (redisplay-recent . BUFFER) -- 1+ alive agent buffers; show MRU.
- (pick-project) -- zero alive agent buffers; prompt.
When 2+ agent buffers are alive, F9 redisplays the most-recently-
selected one rather than opening the project picker. C-F9 is the
explicit \"start a different project\" surface; M-F9 is the explicit
\"switch among existing agents\" surface. F9 keeps a single, simple
job: toggle whichever agent was last in use.
A pure-decision helper so the dispatch logic is exercisable in tests
without firing real `display-buffer' or `quit-window' calls."
(let ((win (cj/--ai-vterm-displayed-agent-window)))
(cond
(win (cons 'toggle-off win))
(t
(let ((buffers (cj/--ai-vterm-agent-buffers)))
(cond
(buffers (cons 'redisplay-recent (car buffers)))
(t '(pick-project))))))))
(defun cj/ai-vterm-pick-project (&optional arg)
"Pick an AI-agent project and open or reuse its vterm.
The project is picked from a filtered completing-read list of dirs
that contain .ai/protocols.org. The vterm buffer is named
\"agent [<basename>]\" and is routed to a right-side window via
`display-buffer-alist'. Multiple projects coexist as separate
buffers; reinvoking on the same project reuses its existing vterm.
With prefix ARG, display the buffer without selecting its window.
Bound to C-F9 -- always shows the project picker, even when an agent
buffer is currently displayed."
(interactive "P")
(let* ((dir (cj/--ai-vterm-pick-project))
(name (cj/--ai-vterm-buffer-name dir))
(buf (cj/--ai-vterm-show-or-create dir name)))
(unless arg
(let ((win (get-buffer-window buf)))
(when win (select-window win))))
buf))
(defun cj/ai-vterm (&optional arg)
"Smart F9 dispatch for the AI-vterm launcher.
Behavior depends on the current state:
- If an AI-vterm buffer is currently displayed in this frame, F9
quits its window (toggle off, buffer stays alive).
- Else, if exactly one alive AI-vterm buffer exists, F9 re-displays
it (DWIM -- the obvious next step is to look at it).
- Else (zero or 2+), F9 falls through to `cj/ai-vterm-pick-project'.
With prefix ARG, display the buffer without selecting its window
when a buffer is being shown (no effect on the toggle-off branch).
See `cj/ai-vterm-pick-project' (C-F9) to force the project picker.
M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'."
(interactive "P")
(pcase (cj/--ai-vterm-dispatch)
(`(toggle-off . ,win)
(cond
;; Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no
;; prior layout for the native undo to restore and deleting would
;; leave the frame empty. Bury and flag, so the next toggle-on
;; (`cj/--ai-vterm-display-saved') restores the agent in place at
;; full frame rather than splitting. Capture geometry for that
;; restore. `bury-buffer' can no-op when the window's prev-buffer
;; history holds only the agent (common right after `C-x 1'), so
;; force a swap to a non-agent buffer to keep the toggle observable.
((one-window-p)
(cj/--ai-vterm-capture-state win)
(setq cj/--ai-vterm-last-was-bury t)
(bury-buffer (window-buffer win))
(when (and (window-live-p win)
(cj/--ai-vterm-buffer-p (window-buffer win)))
(with-selected-window win
(switch-to-buffer (other-buffer (window-buffer win) t)))))
;; Multi-window: `quit-restore-window' is the native undo for a
;; `display-buffer' display. The agent's display path records the
;; matching `quit-restore' state -- `display-buffer-record-window'
;; (type `reuse') in `cj/--ai-vterm-reuse-edge-window' when it takes
;; over a slot, `display-buffer-in-direction' (type `window') when it
;; splits a fresh one. So one call restores the displaced buffer
;; into a reused slot, or deletes a window that was split for the
;; agent. No BURY-OR-KILL argument: burying would move the agent to
;; the end of the buffer list, so with several agents alive the next
;; F9 (`cj/--ai-vterm-dispatch' re-shows the most-recent agent) would
;; bring back a different one instead of the agent just toggled off.
(t
;; Capture geometry first: when the agent had its own split window
;; (axis-mismatch / single-window origin), `quit-restore-window'
;; removes it and the next toggle-on splits afresh -- replaying the
;; captured size preserves a user resize across the toggle. Harmless
;; in the reused-slot case, where the split path is never taken.
(cj/--ai-vterm-capture-state win)
(setq cj/--ai-vterm-last-was-bury nil)
(quit-restore-window win)))
nil)
(`(redisplay-recent . ,buf)
(display-buffer buf)
(unless arg
(let ((w (get-buffer-window buf)))
(when w (select-window w))))
buf)
(`(pick-project)
(cj/ai-vterm-pick-project arg))))
;; ----------------------------- Close an agent --------------------------------
(defun cj/--ai-vterm-kill-tmux-session (session)
"Kill the tmux SESSION via `tmux kill-session -t SESSION'.
Returns the process exit status (0 on success), or nil when tmux is
unavailable or already gone -- a session that no longer exists is not
an error worth surfacing, since the goal is just to make sure it's
down."
(condition-case nil
(process-file "tmux" nil nil nil "kill-session" "-t" session)
(error nil)))
(defun cj/--ai-vterm-close-buffer (buffer)
"Gracefully tear down AI-vterm BUFFER: tmux session, window, buffer.
Derives the tmux session name from BUFFER's `default-directory' (the
project dir the vterm was created in) and kills it so the agent
process stops. Deletes BUFFER's window when it's shown and isn't the
only window in its frame, then kills BUFFER (suppressing the
process-still-running prompt -- the session is already down). No-op
when BUFFER isn't an AI-vterm buffer."
(when (cj/--ai-vterm-buffer-p buffer)
(cj/--ai-vterm-kill-tmux-session
(cj/--ai-vterm-tmux-session-name
(buffer-local-value 'default-directory buffer)))
(let ((win (get-buffer-window buffer)))
(when (and win (> (length (window-list (window-frame win) 'never)) 1))
(delete-window win)))
(let ((kill-buffer-query-functions nil))
(kill-buffer buffer))))
(defun cj/--ai-vterm-close-target ()
"Return the AI-vterm buffer `cj/ai-vterm-close' should act on, or nil.
The current buffer when it is an agent buffer; else the sole live
agent buffer; else a `completing-read' choice among the live agent
buffers; nil when none are alive."
(cond
((cj/--ai-vterm-buffer-p (current-buffer)) (current-buffer))
(t (let ((buffers (cj/--ai-vterm-agent-buffers)))
(cond
((null buffers) nil)
((null (cdr buffers)) (car buffers))
(t (get-buffer
(completing-read "Close AI vterm: "
(mapcar #'buffer-name buffers) nil t))))))))
(defun cj/ai-vterm-close ()
"Gracefully close an AI-vterm agent: kill its tmux session and buffer.
Targets the current agent buffer, the sole live agent, or prompts when
several are alive (see `cj/--ai-vterm-close-target'). Asks for
confirmation first -- this kills the running agent process, which can
interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(interactive)
(let ((buffer (cj/--ai-vterm-close-target)))
(unless buffer
(user-error "No AI-vterm agent buffers to close"))
(let ((name (buffer-name buffer)))
(when (y-or-n-p (format "Close agent %s? This kills its tmux session. "
name))
(cj/--ai-vterm-close-buffer buffer)
(message "Closed agent %s." name)))))
(keymap-global-set "<f9>" #'cj/ai-vterm)
(keymap-global-set "C-<f9>" #'cj/ai-vterm-pick-project)
(keymap-global-set "M-<f9>" #'cj/ai-vterm-close)
(keymap-global-set "C-S-<f9>" #'cj/ai-vterm-close)
;; vterm binds <f1>..<f12> to `vterm--self-insert', so a plain <f9> typed
;; while point is inside an agent buffer gets sent to the terminal program
;; instead of toggling the agent -- which bites hard when the agent buffer is
;; the only window in the frame. Re-bind the F9 family in `vterm-mode-map' so
;; the toggle reaches Emacs from there too. (C-<f9> / M-<f9> aren't in vterm's
;; intercept set, but bind them here as well so the behaviour is uniform.)
(with-eval-after-load 'vterm
(keymap-set vterm-mode-map "<f9>" #'cj/ai-vterm)
(keymap-set vterm-mode-map "C-<f9>" #'cj/ai-vterm-pick-project)
(keymap-set vterm-mode-map "M-<f9>" #'cj/ai-vterm-close)
(keymap-set vterm-mode-map "C-S-<f9>" #'cj/ai-vterm-close))
;; ---------- emacsclient: keep opened files off the agent vterm ----------
;;
;; `server-start' (in system-defaults.el) leaves `server-window' nil, so
;; `server-switch-buffer' opens an `emacsclient -n' file in the *selected*
;; window. When the user is typing in the agent vterm, that's the agent
;; window -- so "tell the agent to open X" would replace the agent buffer
;; with X. The function below, wired as `server-window', routes such files
;; into a non-agent window instead (splitting one off the agent when the
;; agent is the only window). emacsclient invocations from anywhere else
;; fall through to `pop-to-buffer' and behave as before.
(defun cj/--ai-vterm-non-agent-window (&optional exclude)
"Return a window in the selected frame fit to show a non-agent buffer.
Skips the minibuffer, the EXCLUDE window, dedicated windows, and any
window already showing an AI-vterm agent buffer. Returns nil when no
such window exists."
(seq-find (lambda (w)
(and (not (eq w exclude))
(not (window-dedicated-p w))
(not (cj/--ai-vterm-buffer-p (window-buffer w)))))
(window-list (selected-frame) 'never)))
(defun cj/--ai-vterm-server-display (buffer)
"Display BUFFER for `server-window', keeping it off the agent vterm.
When the selected window shows an AI-vterm agent buffer, put BUFFER in
a non-agent window (`cj/--ai-vterm-non-agent-window'), splitting a
left-side window off the agent when the agent is the only window, then
select that window. Otherwise hand off to `pop-to-buffer'. Returns
the window BUFFER ends up in -- the value `server-switch-buffer'
expects from a `server-window' function."
(if (cj/--ai-vterm-buffer-p (window-buffer (selected-window)))
(let* ((agent-win (selected-window))
(target (or (cj/--ai-vterm-non-agent-window agent-win)
(split-window agent-win nil 'left))))
(set-window-buffer target buffer)
(select-window target))
(pop-to-buffer buffer)
(selected-window)))
(defvar server-window)
(with-eval-after-load 'server
(setq server-window #'cj/--ai-vterm-server-display))
(provide 'ai-vterm)
;;; ai-vterm.el ends here
|