diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-13 16:01:51 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-13 16:01:51 -0500 |
| commit | c53671b06e585b5f663e0218490a855e7389d29e (patch) | |
| tree | 07bc3778bb8f624fc6690cc9ae02248eca658d90 | |
| parent | 56a3ed860b85ade4ccbcc42a4149fa9d83c34420 (diff) | |
| download | dotemacs-c53671b06e585b5f663e0218490a855e7389d29e.tar.gz dotemacs-c53671b06e585b5f663e0218490a855e7389d29e.zip | |
feat(eat-config): answer XTWINOPS queries so tmux renders sixel in EAT
tmux 3.7b won't emit sixel until it learns the client's cell pixel size via CSI 14/16/18 t. EAT 0.9.4's parser has no CSI t clause and drops the queries, so images never render. I added a :before advice on eat--t-handle-output that scans the raw output and answers through the terminal's own input function (eat--t-with-env binds eat--t-term). I verified it live: images render and survive window switches, scrolling, and resizing.
I guarded the advice-add so it installs only while EAT itself can't answer. Two answerers give tmux a double reply, and it forwards the second one's raw bytes into the pane as keystrokes. The guard keys on eat--t-send-window-size-report, the function an upstream clause would define.
I kept an upstream-shaped parser-clause patch locally for a PR to akib/emacs-eat.
| -rw-r--r-- | modules/eat-config.el | 96 | ||||
| -rw-r--r-- | tests/test-eat-config--xtwinops.el | 120 |
2 files changed, 216 insertions, 0 deletions
diff --git a/modules/eat-config.el b/modules/eat-config.el index e5b8f0c5..01d0fbe6 100644 --- a/modules/eat-config.el +++ b/modules/eat-config.el @@ -110,6 +110,102 @@ not recognize (which would later trip (cl-assert charset) on write)." (with-eval-after-load 'eat (advice-add 'eat--t-set-charset :filter-args #'cj/--eat-charset-never-nil)) +;; EAT 0.9.4 XTWINOPS gap. tmux 3.7b has native Sixel but refuses to emit it +;; until it learns the client's cell pixel size, which it asks for with the +;; XTWINOPS window-size requests CSI 14 t (text area in pixels), CSI 16 t (cell +;; size in pixels) and CSI 18 t (text area in characters). EAT's parser +;; (eat--t-handle-output) has no `t' case, so it silently drops these -- tmux +;; never learns the geometry and images never render. We can't add the parser +;; clause without forking the vendored 541-line pcase (see the charset note +;; above), so answer the query from a `:before' advice instead: eat--t-handle-output +;; is called inside (eat--t-with-env terminal ...), which dynamically binds +;; eat--t-term, so the advice can read the live display + cell dimensions and +;; write the report back through the terminal's own input function. The reply +;; format matches EAT's existing XTSMGRAPHICS reply (same char-width/height and +;; display fields). Verified live on ratio 2026-07-13: images render and +;; persist across window switches, scrolling, and resizing. An upstream-shaped +;; patch (a real parser clause) is kept locally for a PR to akib/emacs-eat. +;; The advice does NOT become a no-op when upstream +;; ships that clause: it runs :before the parser and scans raw output, so +;; queries always survive to it, and two answerers means tmux gets a double +;; reply -- it treats the second as unrequested input and forwards the raw +;; escape bytes into the pane as keystrokes (this killed an agent session on +;; 2026-07-13 when a second patched parser was live in the daemon). The +;; advice-add is therefore guarded: it installs only while EAT itself cannot +;; answer, and stays out the day the upstream clause (which defines +;; eat--t-send-window-size-report) lands. + +(declare-function eat--t-handle-output "eat") +(declare-function eat--t-term-display "eat") +(declare-function eat--t-term-input-fn "eat") +(declare-function eat--t-term-char-width "eat") +(declare-function eat--t-term-char-height "eat") +(declare-function eat--t-disp-width "eat") +(declare-function eat--t-disp-height "eat") +(defvar eat--t-term) + +(defun cj/--eat-xtwinops-report (n cols rows char-width char-height) + "Return the XTWINOPS reply string for window-size request N, or nil. +COLS and ROWS are the display size in characters; CHAR-WIDTH and +CHAR-HEIGHT are the pixel size of one cell. N is 14 (text area in +pixels), 16 (cell size in pixels), or 18 (text area in characters); any +other N returns nil so the request goes unanswered." + (pcase n + (14 (format "\e[4;%d;%dt" (* rows char-height) (* cols char-width))) + (16 (format "\e[6;%d;%dt" char-height char-width)) + (18 (format "\e[8;%d;%dt" rows cols)))) + +(defun cj/--eat-xtwinops-queries (output) + "Return the XTWINOPS request numbers found in terminal OUTPUT, in order. +Matches only the bare CSI 14/16/18 t window-size requests EAT drops -- a +parametrized form (e.g. CSI 3 ; 14 t) or a different CSI t op (e.g. the +CSI 24 t resize) is not one we answer and is left alone. Returns nil +when OUTPUT carries no such request. OUTPUT is one pty chunk, so a +query split across two reads would be missed; in practice tmux writes +the few-byte query atomically, so it arrives whole." + (let ((start 0) (found '())) + (while (string-match "\e\\[\\(14\\|16\\|18\\)t" output start) + (push (string-to-number (match-string 1 output)) found) + (setq start (match-end 0))) + (nreverse found))) + +(defun cj/--eat-send-window-size-report (n) + "Answer XTWINOPS window-size request N on the current EAT terminal. +Runs with `eat--t-term' dynamically bound (inside `eat--t-with-env'), +reads the live display and cell dimensions, and writes the report back +through the terminal's own input function. A request number EAT does +not report on (`cj/--eat-xtwinops-report' returns nil) is ignored." + (let* ((disp (eat--t-term-display eat--t-term)) + (reply (cj/--eat-xtwinops-report + n + (eat--t-disp-width disp) + (eat--t-disp-height disp) + (eat--t-term-char-width eat--t-term) + (eat--t-term-char-height eat--t-term)))) + (when reply + (funcall (eat--t-term-input-fn eat--t-term) eat--t-term reply)))) + +(defun cj/--eat-answer-xtwinops (output) + "`:before' advice for `eat--t-handle-output'. +Answer every XTWINOPS window-size request in OUTPUT that EAT 0.9.4 would +otherwise drop, so tmux learns the cell pixel size it needs before it +will emit Sixel. Runs inside `eat--t-with-env', so `eat--t-term' is +bound for the responder." + (mapc #'cj/--eat-send-window-size-report (cj/--eat-xtwinops-queries output))) + +(defun cj/--eat-xtwinops-advice-needed-p () + "Return non-nil when EAT itself cannot answer XTWINOPS window-size queries. +EAT 0.9.4 has no CSI t parser clause, so the advice must answer them. +An EAT that ships the clause defines `eat--t-send-window-size-report' +(the function the upstream-shaped parser patch adds); with that present +the advice must stay out, or every query gets two replies and tmux +types the second one's raw bytes into the pane." + (not (fboundp 'eat--t-send-window-size-report))) + +(with-eval-after-load 'eat + (when (cj/--eat-xtwinops-advice-needed-p) + (advice-add 'eat--t-handle-output :before #'cj/--eat-answer-xtwinops))) + ;; ------------------------------- eat package --------------------------------- (defun cj/--eat-clear-mode-line-process () diff --git a/tests/test-eat-config--xtwinops.el b/tests/test-eat-config--xtwinops.el new file mode 100644 index 00000000..29f87f2f --- /dev/null +++ b/tests/test-eat-config--xtwinops.el @@ -0,0 +1,120 @@ +;;; test-eat-config--xtwinops.el --- Tests for the EAT XTWINOPS window-size reply -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for the XTWINOPS (CSI <n> t) window-size responder. eat 0.9.4 +;; has no CSI <n> t handler, so it silently drops the window-size requests +;; tmux 3.7b sends to learn the cell pixel size it needs before it will emit +;; Sixel -- images then never render inside EAT. The module answers three +;; requests: 14 (text area in pixels), 16 (cell size in pixels), 18 (text area +;; in characters). +;; +;; Two pure pieces carry the logic and are tested here directly: +;; - `cj/--eat-xtwinops-report' computes the reply string from the display and +;; cell dimensions. +;; - `cj/--eat-xtwinops-queries' extracts the request numbers from a chunk of +;; terminal output. +;; The thin accessor glue (`cj/--eat-send-window-size-report', which reads the +;; live `eat--t-term' struct) is verified in the running daemon, since eat's +;; structs are not loadable under `make test' (no package-initialize). The +;; detector's dispatch is tested against a recording stub of the responder. + +;;; Code: + +(require 'ert) + +;; Stub keymap dep before loading the module (matches the other module tests). +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'eat-config) + +;;; --------------------------- reply computation ---------------------------- + +(ert-deftest test-eat-config-xtwinops-report-normal-14-text-area-pixels () + "Normal: request 14 reports text-area size in pixels (rows*ch by cols*cw)." + ;; 80x24 chars, 10x20 px cells -> height 24*20=480, width 80*10=800. + (should (equal (cj/--eat-xtwinops-report 14 80 24 10 20) + "\e[4;480;800t"))) + +(ert-deftest test-eat-config-xtwinops-report-normal-16-cell-pixels () + "Normal: request 16 reports the cell size in pixels (height then width)." + (should (equal (cj/--eat-xtwinops-report 16 80 24 10 20) + "\e[6;20;10t"))) + +(ert-deftest test-eat-config-xtwinops-report-normal-18-text-area-chars () + "Normal: request 18 reports the text-area size in characters (rows then cols)." + (should (equal (cj/--eat-xtwinops-report 18 80 24 10 20) + "\e[8;24;80t"))) + +(ert-deftest test-eat-config-xtwinops-report-boundary-unit-cells () + "Boundary: with eat's default 1x1 px cells, pixel dims equal the char dims." + (should (equal (cj/--eat-xtwinops-report 14 80 24 1 1) "\e[4;24;80t")) + (should (equal (cj/--eat-xtwinops-report 16 80 24 1 1) "\e[6;1;1t"))) + +(ert-deftest test-eat-config-xtwinops-report-error-unknown-request-is-nil () + "Error: any request number other than 14/16/18 returns nil (unanswered)." + (should (null (cj/--eat-xtwinops-report 15 80 24 10 20))) + (should (null (cj/--eat-xtwinops-report 24 80 24 10 20))) + (should (null (cj/--eat-xtwinops-report nil 80 24 10 20)))) + +;;; ----------------------------- query detection ---------------------------- + +(ert-deftest test-eat-config-xtwinops-queries-normal-single () + "Normal: a lone CSI 14 t query is detected." + (should (equal (cj/--eat-xtwinops-queries "\e[14t") '(14)))) + +(ert-deftest test-eat-config-xtwinops-queries-normal-embedded-multiple () + "Normal: several queries embedded in other output are returned in order." + (should (equal (cj/--eat-xtwinops-queries "foo\e[14tbar\e[18tbaz\e[16t") + '(14 18 16)))) + +(ert-deftest test-eat-config-xtwinops-queries-boundary-none () + "Boundary: output with no XTWINOPS query returns nil, including empty." + (should (null (cj/--eat-xtwinops-queries ""))) + (should (null (cj/--eat-xtwinops-queries "hello\e[0m\e[2J")))) + +(ert-deftest test-eat-config-xtwinops-queries-error-unhandled-ops-ignored () + "Error: CSI t ops we do not answer (and parametrized forms) are not matched. +`\\e[24t' is a resize op, `\\e[3;14t' carries a leading param -- neither is the +bare CSI 14/16/18 t we answer, so both are ignored." + (should (null (cj/--eat-xtwinops-queries "\e[24t"))) + (should (null (cj/--eat-xtwinops-queries "\e[3;14t"))) + (should (null (cj/--eat-xtwinops-queries "\e[114t")))) + +;;; --------------------------- advice-needed guard -------------------------- + +(ert-deftest test-eat-config-xtwinops-advice-needed-normal-eat-0-9-4 () + "Normal: on eat 0.9.4 (no upstream CSI t clause) the advice is needed." + ;; The upstream parser clause defines `eat--t-send-window-size-report'; + ;; 0.9.4 does not, so it must be unbound here. + (should-not (fboundp 'eat--t-send-window-size-report)) + (should (cj/--eat-xtwinops-advice-needed-p))) + +(ert-deftest test-eat-config-xtwinops-advice-needed-boundary-upstream-ships () + "Boundary: once upstream defines the parser clause, the advice must NOT +install -- both would answer and tmux gets a double reply." + (cl-letf (((symbol-function 'eat--t-send-window-size-report) #'ignore)) + (should-not (cj/--eat-xtwinops-advice-needed-p)))) + +;;; ------------------------- detector dispatch (seam) ----------------------- + +(ert-deftest test-eat-config-xtwinops-answer-dispatches-once-per-query () + "Integration: the detector calls the responder once per query, in order. +Mocks only our own responder seam (`cj/--eat-send-window-size-report'); the +detector under test does the real query extraction." + (let ((calls '())) + (cl-letf (((symbol-function 'cj/--eat-send-window-size-report) + (lambda (n) (push n calls)))) + (cj/--eat-answer-xtwinops "\e[14t\e[16t\e[18t")) + (should (equal (nreverse calls) '(14 16 18))))) + +(ert-deftest test-eat-config-xtwinops-answer-no-query-no-call () + "Boundary: output with no query never calls the responder." + (let ((called nil)) + (cl-letf (((symbol-function 'cj/--eat-send-window-size-report) + (lambda (_n) (setq called t)))) + (cj/--eat-answer-xtwinops "no query here\e[2J")) + (should-not called))) + +(provide 'test-eat-config--xtwinops) +;;; test-eat-config--xtwinops.el ends here |
