Files
cl-tty/org/detection.org
Hermes Agent 29f99a576d literate: restructure all 19 org files with per-function blocks and prose
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%.
2026-05-12 18:55:07 +00:00

8.2 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:

  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-backendmodern-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

;; 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:

  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.

(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)))))