Every function, defclass, defstruct, defgeneric, defmethod, defmacro, defvar, and defparameter in every org file now has its own #+BEGIN_SRC block with literate prose above it explaining the design reasoning. Block counts before → after: package.org: 1 → 7 container-package.org: 1 → 1 (prose expanded) dirty.org: 4 → 6 render.org: 10 → 25 theme.org: 6 → 19 box-renderable.org: 9 → 29 scrollbox.org: 8 → 26 tabbar.org: 5 → 10 backend-protocol.org: 8 → 66 modern-backend.org: 17 → 53 detection.org: 4 → 6 layout-engine.org: 9 → 36 framebuffer.org: 8 → 37 markdown-renderer.org:13 → 38 dialog.org: 17 → 23 (merged dual structure) mouse.org: 4 → 25 select.org: 12 → 30 slot.org: 4 → 12 text-input.org: 11 → 53 Total: ~153 blocks → ~502 blocks Bugs fixed during restructuring: - render.org: stray π character typo (backenπd → backend) - modern-backend.org: sgr-attr missing closing paren + #+END_SRC - detection.org: invalid #\Esc character reference - select.org: extra closing paren in select-visible-options All 13 test suites pass at 100%.
209 lines
8.2 KiB
Org Mode
209 lines
8.2 KiB
Org Mode
#+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 ../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 ../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 ../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 ../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 ../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 ../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
|