8.3 KiB
Terminal Capability Detection (v0.12.0)
Overview
Currently, users must manually choose between modern-backend and
simple-backend when initializing cl-tty. This module adds auto-detection:
- Check if stdout is a real TTY (not piped/redirected)
- Check the
COLORTERMenvironment variable for truecolor support - Optionally query the terminal via DA1/DA3 escape sequences
- 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-backendorsimple-backendAuto-detect and return the appropriate backend. Results are cached in*detected-backend*.detect-backend-by-env→:modernornilCheckCOLORTERMenv var fortruecoloror24bit.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.
- Create
detection.lispwith all detection functions - Wire into ASDF
- Update
demo.lispto usedetect-backend - Tangle, test, commit
Tests
;; 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*)))))
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.
(in-package :cl-tty.backend)
(defvar *detected-backend* nil
"Cached backend instance from detect-backend. Nil = not yet detected.")
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).
(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)))
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.
(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*))
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.
(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)))
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.
(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))))
Orchestrator
The detect-backend function ties all probes together with a short-circuit
caching strategy. On first call, it:
- Checks if stdout is a real TTY (fast, gates all I/O)
- Checks
COLORTERM(fast, most reliable signal) - 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.
(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)))))