summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-20 18:10:05 -0400
committerCraig Jennings <c@cjennings.net>2026-05-20 18:10:05 -0400
commitfeedb78a517a1e86f6bb467756aa2605c7477223 (patch)
tree9034c36e430189e7ef19fa74c9ce1d5442ea914d
parent4a6201dd0117df55d164cee969f7c3c8123f6b28 (diff)
downloaddotemacs-feedb78a517a1e86f6bb467756aa2605c7477223.tar.gz
dotemacs-feedb78a517a1e86f6bb467756aa2605c7477223.zip
feat(ai-vterm): default to bottom-75% on laptop, right-50% on desktop
The agent window's default placement was hardcoded to a right-side split at 50% width. That's wrong on a laptop, where the screen is shorter and a bottom split with more height fits better than a narrow side panel. Pick the default from the host: bottom at 75% height on a laptop, right at 50% width on a desktop, branching on env-laptop-p in cj/--ai-vterm-default-direction and cj/--ai-vterm-default-size. The defaults still feed the existing toggle-capture mechanism, so re-orienting the window mid-session sticks the same way it did before. Renamed cj/ai-vterm-window-width to cj/ai-vterm-desktop-width and added cj/ai-vterm-laptop-height so each axis has its own knob.
-rw-r--r--modules/ai-vterm.el70
-rw-r--r--tests/test-ai-vterm--default-geometry.el56
-rw-r--r--tests/test-ai-vterm--display-rule.el22
-rw-r--r--tests/test-ai-vterm--display-saved.el29
4 files changed, 145 insertions, 32 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el
index 4a8f7e0e..266966ea 100644
--- a/modules/ai-vterm.el
+++ b/modules/ai-vterm.el
@@ -7,10 +7,13 @@
;; 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 right-side window via
-;; display-buffer-alist. Multiple projects produce multiple coexisting
-;; buffers that share the same right-side slot; switching among them is a
-;; buffer-switch, not a kill-and-recreate.
+;; instruction to it, and routes the buffer to a side window via
+;; display-buffer-alist. The default placement is host-aware: 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-").
@@ -47,6 +50,7 @@
(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))
@@ -332,17 +336,44 @@ the active group alphabetical too."
(let ((proc (get-buffer-process buffer)))
(and proc (process-live-p proc))))
-(defcustom cj/ai-vterm-window-width 0.5
- "Default fraction of frame allocated to the AI-vterm window.
+(defcustom cj/ai-vterm-desktop-width 0.5
+ "Default fraction of frame width for the AI-vterm window on a desktop.
-Used by `cj/--ai-vterm-display-saved' 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). Applies to both width and
-height axes -- the same fallback fraction is used for either default
-direction."
+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.
@@ -366,7 +397,7 @@ the saved direction.")
Positive integer: body-columns when `cj/--ai-vterm-last-direction'
is right or left, body-lines when below or above. nil means use
-the customizable default `cj/ai-vterm-window-width' (a float
+the host-aware default from `cj/--ai-vterm-default-size' (a float
fraction).
Body size, not total size, because total-width includes the
@@ -392,10 +423,12 @@ cost of not auto-scaling if the frame itself resizes.")
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 `right' -- the module's side-panel
-default. Does nothing when WINDOW is not live."
+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 'right
+ window (cj/--ai-vterm-default-direction)
'cj/--ai-vterm-last-direction
'cj/--ai-vterm-last-size))
@@ -429,7 +462,8 @@ 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 `right' and `cj/ai-vterm-window-width'."
+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)
@@ -440,8 +474,8 @@ F9 state vars, falling back to `right' and `cj/ai-vterm-window-width'."
(setq cj/--ai-vterm-last-was-bury nil)
(cj/window-toggle-display-saved
buffer alist
- 'cj/--ai-vterm-last-direction 'right
- 'cj/--ai-vterm-last-size cj/ai-vterm-window-width))))
+ '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.
diff --git a/tests/test-ai-vterm--default-geometry.el b/tests/test-ai-vterm--default-geometry.el
new file mode 100644
index 00000000..f8ec08c9
--- /dev/null
+++ b/tests/test-ai-vterm--default-geometry.el
@@ -0,0 +1,56 @@
+;;; test-ai-vterm--default-geometry.el --- Tests for host-aware display defaults -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ai-vterm's default display geometry is host-aware: a laptop opens the
+;; agent from the bottom (75% height), a desktop opens it from the right
+;; (50% width). `cj/--ai-vterm-default-direction' and
+;; `cj/--ai-vterm-default-size' encapsulate the `env-laptop-p' branch;
+;; they feed the default fallbacks in `cj/--ai-vterm-capture-state' and
+;; `cj/--ai-vterm-display-saved'.
+;;
+;; `env-laptop-p' is stubbed per-test so the assertions are deterministic
+;; regardless of the host the suite runs on.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(ert-deftest test-ai-vterm--default-direction-laptop ()
+ "Normal: on a laptop the default direction is `below'."
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)))
+ (should (eq (cj/--ai-vterm-default-direction) 'below))))
+
+(ert-deftest test-ai-vterm--default-direction-desktop ()
+ "Normal: on a desktop the default direction is `right'."
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (should (eq (cj/--ai-vterm-default-direction) 'right))))
+
+(ert-deftest test-ai-vterm--default-size-laptop ()
+ "Normal: on a laptop the default size is `cj/ai-vterm-laptop-height'."
+ (let ((cj/ai-vterm-laptop-height 0.75)
+ (cj/ai-vterm-desktop-width 0.5))
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)))
+ (should (= (cj/--ai-vterm-default-size) 0.75)))))
+
+(ert-deftest test-ai-vterm--default-size-desktop ()
+ "Normal: on a desktop the default size is `cj/ai-vterm-desktop-width'."
+ (let ((cj/ai-vterm-laptop-height 0.75)
+ (cj/ai-vterm-desktop-width 0.5))
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (should (= (cj/--ai-vterm-default-size) 0.5)))))
+
+(ert-deftest test-ai-vterm--default-size-respects-custom-values ()
+ "Boundary: the helper returns the customized values, not the literals."
+ (let ((cj/ai-vterm-laptop-height 0.6)
+ (cj/ai-vterm-desktop-width 0.33))
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)))
+ (should (= (cj/--ai-vterm-default-size) 0.6)))
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (should (= (cj/--ai-vterm-default-size) 0.33)))))
+
+(provide 'test-ai-vterm--default-geometry)
+;;; test-ai-vterm--default-geometry.el ends here
diff --git a/tests/test-ai-vterm--display-rule.el b/tests/test-ai-vterm--display-rule.el
index 15d270e2..9b70134a 100644
--- a/tests/test-ai-vterm--display-rule.el
+++ b/tests/test-ai-vterm--display-rule.el
@@ -9,6 +9,7 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'ai-vterm)
@@ -27,19 +28,22 @@
,@body)))
(ert-deftest test-ai-vterm--display-rule-routes-agent-buffer-to-right ()
- "Normal: a buffer named \"agent [foo]\" lands in a window to the right.
+ "Normal: on a desktop, \"agent [foo]\" lands in a window to the right.
-The rule uses `display-buffer-in-direction' with `(direction . right)',
-which splits the current window so the new window's left edge sits at
-a positive column. The buffer winds up in that new window."
+The desktop default direction is `right' (see
+`cj/--ai-vterm-default-direction'), so the rule splits the current
+window with `(direction . right)' and the new window's left edge
+sits at a positive column. `env-laptop-p' is stubbed nil to pin the
+desktop branch; on a laptop the agent would land below instead."
(let ((name "agent [display-rule-test]"))
(test-ai-vterm--cleanup name)
(unwind-protect
- (test-ai-vterm--with-clean-frame
- (let* ((buf (get-buffer-create name))
- (win (display-buffer buf)))
- (should (windowp win))
- (should (> (window-left-column win) 0))))
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (test-ai-vterm--with-clean-frame
+ (let* ((buf (get-buffer-create name))
+ (win (display-buffer buf)))
+ (should (windowp win))
+ (should (> (window-left-column win) 0)))))
(test-ai-vterm--cleanup name))))
(ert-deftest test-ai-vterm--display-rule-skips-non-matching-buffer ()
diff --git a/tests/test-ai-vterm--display-saved.el b/tests/test-ai-vterm--display-saved.el
index a17df0aa..91cea46e 100644
--- a/tests/test-ai-vterm--display-saved.el
+++ b/tests/test-ai-vterm--display-saved.el
@@ -20,16 +20,18 @@
(require 'ai-vterm)
(require 'testutil-vterm-buffers)
-(ert-deftest test-ai-vterm--display-saved-uses-defaults-when-state-nil ()
- "Normal: nil state -> direction=rightmost, size=cj/ai-vterm-window-width.
+(ert-deftest test-ai-vterm--display-saved-uses-desktop-defaults-when-state-nil ()
+ "Normal: nil state on a desktop -> rightmost, size=cj/ai-vterm-desktop-width.
The cardinal `right' default maps to the frame-edge variant
`rightmost' so agent lands at the frame's right edge regardless of
-which window is selected."
+which window is selected. `env-laptop-p' is stubbed nil to pin the
+desktop branch."
(let (received-buf received-alist
(cj/--ai-vterm-last-direction nil)
(cj/--ai-vterm-last-size nil)
- (cj/ai-vterm-window-width 0.5))
- (cl-letf (((symbol-function 'display-buffer-in-direction)
+ (cj/ai-vterm-desktop-width 0.5))
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))
+ ((symbol-function 'display-buffer-in-direction)
(lambda (b a)
(setq received-buf b received-alist a)
'fake-window)))
@@ -39,6 +41,23 @@ which window is selected."
(should (= (cdr (assq 'window-width received-alist)) 0.5))
(should (eq (cdr (assq 'inhibit-same-window received-alist)) t))))
+(ert-deftest test-ai-vterm--display-saved-uses-laptop-defaults-when-state-nil ()
+ "Normal: nil state on a laptop -> bottom, size=cj/ai-vterm-laptop-height.
+The cardinal `below' default maps to the frame-edge variant `bottom'
+and the size lands on the `window-height' axis. `env-laptop-p' is
+stubbed t to pin the laptop branch."
+ (let (received-alist
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil)
+ (cj/ai-vterm-laptop-height 0.75))
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))
+ ((symbol-function 'display-buffer-in-direction)
+ (lambda (_b a) (setq received-alist a) 'fake-window)))
+ (cj/--ai-vterm-display-saved 'fake-buf '((inhibit-same-window . t))))
+ (should (eq (cdr (assq 'direction received-alist)) 'bottom))
+ (should (= (cdr (assq 'window-height received-alist)) 0.75))
+ (should-not (assq 'window-width received-alist))))
+
(ert-deftest test-ai-vterm--display-saved-uses-saved-direction-and-size-below ()
"Normal: saved direction=below maps to bottom edge; size=0.4 passes through."
(let (received-alist