- Move backend/ and layout/ directories into src/ - Update all path references in ASD, scripts, docs - Convert README.org from Markdown syntax to proper Org-mode - Fix demo.lisp use-package conflict (both backend and input export #:read-event) - Fix modern-backend TIOCGWINSZ ioctl alien type (alien-sap wrapper) - Add v0.15.0 section to ROADMAP, update line count to 5760 - Add known gaps (suspend/resume-backend, slot modes) to v1.0.0 checklist - Remove docs/plans/, debug-layout.lisp, system-index.txt, ci-watchdog.sh - Move tangle.py to Hermes skill (org-babel-tangle) - Add .gitignore for fasl files
6.4 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.
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.
Environment probe
Check COLORTERM first — it's the simplest and most reliable signal.
(in-package :cl-tty.backend)
;;; ─── Detection cache ────────────────────────────────────────────────────────
(defvar *detected-backend* nil
"Cached backend instance from detect-backend. Nil = not yet detected.")
;;; ─── Environment probe ──────────────────────────────────────────────────────
(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
Check if stdout is connected to a terminal (not a pipe or file).
;;; ─── TTY probe ──────────────────────────────────────────────────────────────
(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*))
DA1 terminal query (best-effort)
Send a DA1 (Device Attributes) query and briefly listen for a response. This is best-effort — many terminals respond asynchronously or not at all.
Bug Fixes (v1.0.0): query-terminal stream fix
query-terminal originally used *query-io* for both writing the query and
reading the response. In raw terminal mode, the terminal's response arrives on
stdin, not on the query I/O stream. This caused query-terminal to never
receive a response on certain terminal emulators.
Fix: Write queries to *standard-output* and read responses from
*standard-input*. This matches where the terminal actually delivers its
DA1/DA3 response bytes.
;;; ─── DA1 terminal query ─────────────────────────────────────────────────────
(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)))
(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" #\Esc))))
(when response
;; DA1 response format: ESC [ ? digits ; digits c
;; Kitty reports code 62 in the response
(search "?62" response))))
Orchestrator
Tie all probes together into detect-backend.
;;; ─── Orchestrator ───────────────────────────────────────────────────────────
(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)))))