#+TITLE: Terminal Capability Detection (v0.12.0) #+DATE: 2026-05-11 #+AUTHOR: Amr Gharbeia / Hermes #+STARTUP: content * Overview Currently, users must manually choose between ~modern-backend~ and ~simple-backend~ when initializing cl-tty. This module adds auto-detection: 1. Check if stdout is a real TTY (not piped/redirected) 2. Check the =COLORTERM= environment variable for truecolor support 3. Optionally query the terminal via DA1/DA3 escape sequences 4. Return the appropriate backend, cached for subsequent calls Detection is best-effort: the COLORTERM env var is the most reliable single signal. DA1 queries are asynchronous and many terminals don't respond. If detection can't determine modern capability, it falls back to ~simple-backend~. ** Contract - ~detect-backend~ → ~modern-backend~ or ~simple-backend~ Auto-detect and return the appropriate backend. Results are cached in ~*detected-backend*~. - ~detect-backend-by-env~ → ~:modern~ or ~nil~ Check =COLORTERM= env var for ~truecolor~ or ~24bit~. - ~detect-backend-by-tty~ → boolean Check if stdout is a real terminal (not a pipe). - ~detect-backend-by-da1~ → boolean Send DA1 (~ESC[c~) query and check for modern feature responses. - ~*detected-backend*~ — variable Cache for detection result. ~nil~ = not yet detected. - ~query-terminal~ — function Low-level escape sequence query helper shared by probes. * Plan See =docs/plans/2026-05-11-terminal-detection.md= for implementation tasks. 1. Create ~detection.lisp~ with all detection functions 2. Wire into ASDF 3. Update ~demo.lisp~ to use ~detect-backend~ 4. Tangle, test, commit * Tests #+BEGIN_SRC lisp :tangle no ;; Tests are manually added to src/backend/tests.lisp (def-test detection-returns-backend-instance () (let ((be (cl-tty.backend:detect-backend))) (is-true (typep be 'cl-tty.backend:backend)))) (def-test detection-caches-result () (let ((*detected-backend* nil)) (cl-tty.backend:detect-backend) (is-true (not (null cl-tty.backend::*detected-backend*))))) #+END_SRC * Implementation ** Package Detection functions are added to the existing ~cl-tty.backend~ package. No new package definition needed. ** Detection cache The ~*detected-backend*~ special variable holds the cached backend instance after the first successful detection. Initializing it to ~nil~ gives downstream code a simple truthiness check — ~(or *detected-backend* ...)~ — so that ~detect-backend~ returns immediately on re-entry without re-running probes. Using a global variable rather than a closure or class slot keeps the detection path stateless and trivially resettable for testing: binding ~*detected-backend*~ to ~nil~ forces a fresh detection run. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/detection.lisp (in-package :cl-tty.backend) (defvar *detected-backend* nil "Cached backend instance from detect-backend. Nil = not yet detected.") #+END_SRC ** Environment probe The ~COLORTERM~ environment variable is the single most reliable signal for truecolor support. It is set by modern terminal emulators (kitty, Alacritty, GNOME Terminal, iTerm2, Windows Terminal) and has near-zero false-positive rate. Checking it first avoids the I/O costs and race conditions of escape sequence queries. Case-insensitive matching via ~char-equal~ handles variances across platforms (GNOME Terminal uses ~truecolor~, some Windows builds use ~24bit~). #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/detection.lisp (defun detect-backend-by-env () "Check COLORTERM environment variable for modern terminal support. Returns :modern if COLORTERM contains 'truecolor' or '24bit', nil otherwise." (let ((colorterm (sb-ext:posix-getenv "COLORTERM"))) (when (and colorterm (or (search "truecolor" colorterm :test #'char-equal) (search "24bit" colorterm :test #'char-equal))) :modern))) #+END_SRC ** TTY probe The ~interactive-stream-p~ function from the CL standard reliably distinguishes real terminals from pipes and redirected files. If stdout is not a terminal, escape sequence queries will hang or produce garbage, so this check gates all further (I/O-dependent) probes. Must happen before ~detect-backend-by-da1~. Testing this predicate first also avoids wasting time on DA1 queries when the output is consumed by a test runner, CI pipeline, or pipe. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/detection.lisp (defun detect-backend-by-tty () "Check if stdout is a real terminal (not a pipe/redirect). Returns T if stdout is interactive, nil otherwise." (interactive-stream-p *standard-output*)) #+END_SRC ** Low-level terminal query helper The ~query-terminal~ function encapsulates the mechanics of sending an escape sequence and collecting a response within a short timeout. Writing to ~*standard-output*~ and reading from ~*standard-input*~ matches how terminal emulators actually deliver DA1/DA3 response bytes — they arrive on stdin, not on any query I/O stream. The original implementation used ~*query-io*~ for both sides, which silently failed on some emulators. Using ~listen~ in a polling loop with ~read-char-no-hang~ captures whatever bytes arrive within the timeout without blocking. The ~0.1~ second default strikes a balance between responsiveness and reliability: fast enough to avoid noticeable delay in interactive use, long enough for most terminals to reply. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/detection.lisp (defun query-terminal (query &optional (timeout 0.1)) "Send QUERY string to terminal and return any response received within TIMEOUT seconds. Returns the response string, or nil if no response." (write-string query *standard-output*) (force-output *standard-output*) (sleep timeout) (let ((response (make-array 0 :element-type 'character :fill-pointer 0 :adjustable t))) (loop while (listen *standard-input*) do (vector-push-extend (read-char-no-hang *standard-input*) response)) (when (plusp (length response)) response))) #+END_SRC ** DA1 capability probe The DA1 (Device Attributes) escape sequence (~ESC[c~) is an xterm-standard query that asks the terminal to report its feature set. Modern terminals (notably Kitty, which returns code 62) advertise their capabilities in the response. Searching for ~?62~ in the raw response is a heuristic — it targets Kitty's protocol extension identifier while being short enough to match variants across terminal implementations. This probe is best-effort: many terminals do not respond within the timeout, and some return different codes for the same capabilities. A ~nil~ result from this function should never prevent fallback detection via environment variables. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/detection.lisp (defun detect-backend-by-da1 () "Send DA1 (ESC[c) query and check for kitty terminal response code. Returns T if terminal reports kitty compatibility codes." (let ((response (query-terminal (format nil "~C[c" (code-char 27))))) (when response ;; DA1 response format: ESC [ ? digits ; digits c ;; Kitty reports code 62 in the response (search "?62" response)))) #+END_SRC ** Orchestrator The ~detect-backend~ function ties all probes together with a short-circuit caching strategy. On first call, it: 1. Checks if stdout is a real TTY (fast, gates all I/O) 2. Checks ~COLORTERM~ (fast, most reliable signal) 3. Falls back to DA1 query (slow, best-effort, skipped if env check passed) The ~and~ / ~or~ structure naturally short-circuits: if ~detect-backend-by-tty~ returns nil, the expensive DA1 query never runs. If ~detect-backend-by-env~ returns ~:modern~, the DA1 query is skipped. The result is cached in ~*detected-backend*~ so subsequent calls are effectively free. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/detection.lisp (defun detect-backend () "Auto-detect the appropriate backend for the current terminal. Returns a backend instance (modern-backend or simple-backend). Result is cached in *detected-backend* for subsequent calls." (or *detected-backend* (setf *detected-backend* (if (and (detect-backend-by-tty) (or (eql (detect-backend-by-env) :modern) (detect-backend-by-da1))) (make-modern-backend) (make-simple-backend))))) #+END_SRC