Files
cl-tty/README.org
Hermes Agent 7f4f712399 v0.15.1: EOF/Escape fixes, box title rendering, full feature verification
Bug fixes:
  - read-raw-byte now returns (values nil :eof) on stdin EOF
    instead of just nil, so callers can distinguish EOF from
    timeout.  Previously, non-TTY stdin (pipes, /dev/null)
    caused a busy-spin: sb-posix:read returned 0 immediately,
    read-raw-byte returned nil, the demo loop treated nil as
    'no event yet' and spun at 100% CPU producing 86MB of
    repeated rendering frames.

  - %read-escape-sequence now uses a 50ms timeout on the first
    follow-up byte to resolve the classic Escape-key ambiguity:
    a lone Escape press returned an :escape key-event instead of
    blocking indefinitely on VMIN=1 VTIME=0.  All callers
    (SS3, CSI, Alt+char) propagate :eof instead of faking
    :escape events when EOF occurs mid-sequence.

  - parse-csi-params now uses multiple-value-bind on read-raw-byte
    to preserve the :eof signal through CSI parsing.

  - simple-backend draw-border now renders :title on the top
    edge instead of declaring it (ignore).  The title was
    silently swallowed — the box rendered with the right border
    frame but the title text was never written.

  - demo.lisp: removed 'q' as quit key (conflicted with text
    input).  Only Esc and Ctrl+C quit.  Widget event forwarding
    scoped to tab 1 (Widgets tab).  EOF handling in main loop.
  - Stale help text (still said 'q/esc: quit') updated.

Verification infrastructure:
  - PTY-based demo test (17 checks) spawns the demo in a real
    pseudo-terminal, sends actual keystrokes, reads terminal
    output back.  Verifies: startup rendering, tab switching,
    key dispatch, 'q' doesn't quit, Escape quits via timeout,
    Ctrl+C quits, EOF clean exit, no busy-spin.

  - API feature verification (29 checks) exercises every major
    component through the actual exported API: Simple backend,
    Box with title, Text attributes, draw-rect, TextInput
    (insert/backspace/cursor/Ctrl-A/E), TextArea, key/mouse
    events, Layout flex, Markdown, Theme presets (dark/light/
    nord), Select filtering, Dialog stack, Mouse hit-test,
    Framebuffer, Dirty tracking, Modern backend, draw-ellipsis/
    draw-link, Render dispatch, Detection, Capabilities.

  - Testing pattern saved as skill (tui-pty-testing) for reuse.

Unit tests: 392/392 passing.  All 12 test suites green.
2026-05-12 10:58:27 +00:00

12 KiB
Raw Blame History

Pure CL terminal UI framework. No ncurses, no FFI, no external dependencies.

```lisp (ql:quickload :cl-tty) ```

## Quick start

The simplest possible cl-tty program — detect the terminal, draw some text, read a key, and shut down:

```lisp (sb-posix:with-raw-terminal (let* ((be (cl-tty.backend:detect-backend)) (w 80) (h 24)) (cl-tty.backend:initialize-backend be) (unwind-protect (progn (cl-tty.backend:draw-text be 0 0 "Hello, terminal!" :green nil :bold t) (cl-tty.backend:draw-border be 0 1 30 5 :style :single) (finish-output) ;; Read one key (blocks) (cl-tty.input:read-event be)) (cl-tty.backend:shutdown-backend be)))) ```

Or run the full interactive demo:

```bash sbcl script demo.lisp ```

## Architecture

Two backends, one protocol:

  • modern-backend — truecolor 24-bit, OSC 8 hyperlinks, DECICM sync, SGR mouse, kitty keyboard, bold/italic/underline, box-drawing chars
  • simple-backend — ASCII art, no color, universal compatibility (pipe-safe)

Everything is pure escape sequences (no curses, no terminfo, no FFI).

### Backend protocol

Every drawing operation is a CLOS generic function dispatched on the backend class. Programs never call terminal codes directly:

```lisp ;; Lifecycle (initialize-backend backend) (shutdown-backend backend)

;; Drawing (draw-text backend x y string fg bg &key bold italic underline reverse dim) (draw-border backend x y width height &key style fg bg title) (draw-rect backend x y width height &key bg) (draw-link backend x y string url &key fg bg)

;; Input (read-event backend &key timeout) → key-event, mouse-event, :eof, or nil (backend-size backend) → (values columns lines)

;; Cursor (cursor-move backend x y) (cursor-hide backend) (cursor-show backend) (cursor-style backend shape &key blink) ;; :bar :block :underline ```

### Event loop pattern

```lisp (let ((be (detect-backend))) (initialize-backend be) (loop with running = t while running do (backend-clear be) ;; … draw frame … (finish-output standard-output) (let ((event (read-event be))) (typecase event (key-event (when (eql (key-event-key event) :escape) (setf running nil))) (mouse-event ;; handle mouse )) (when (eq event :eof) (setf running nil)))) (shutdown-backend be)) ```

### Layout system

Pure CL flexbox layout engine. No C dependencies, no Yoga FFI.

```lisp ;; Macros build layout-trees: (vbox (:gap 1 :padding 1) (header "Title") (hbox (:grow 1) (sidebar (:width 30) …) (content …))) ```

Layout properties: `:direction` (`:row` / `:column`), `:grow`, `:shrink`, `:basis`, `:gap`, `:padding`, `:margin`, `:width`, `:height`, `:wrap`.

See `layout/layout.lisp` or `org/layout-engine.org` for the full API.

### Rendering pipeline

Component trees render through a coordinated pipeline:

  1. Layout pass — `compute-layout` traverses dirty branches, solves flex constraints
  2. Render dispatch — `render` generic dispatches per component type
  3. Framebuffer — (optional) `make-framebuffer-backend` captures to a cell array, `diff-framebuffers` computes minimal changes, `flush-framebuffer` writes only changed cells

```lisp ;; Full pipeline with framebuffer (let* ((fb-be (make-framebuffer-backend :width 80 :height 24)) (fb (fb-framebuffer fb-be))) (render my-component fb-be) (flush-framebuffer prev-fb fb real-backend)) ```

## Components

Component What it does Status
Box Bordered container with background, title stable
Text Styled text with word-wrap, spans stable
ScrollBox Scrollable viewport with scrollbars stable
TabBar Horizontal tab navigation stable
Select Dropdown with fuzzy filter, category headers stable
TextInput Single-line text input with readline keybindings stable
TextArea Multi-line input with undo/redo, cursor movement stable
Markdown Renders markdown with syntax highlighting + diffs stable
Dialog Modal overlays with stack management stable
Toast Transient notifications (info/success/warning/error) stable
Mouse Event handlers, hit-testing, text selection stable
Slot Plugin system — named slots for extensible UI stable

Each component follows a consistent pattern:

```lisp ;; 1. Create — factory function returns instance (let ((input (make-text-input :placeholder "Type here…")) (box (make-box :border-style :single :title "My Box")))

;; 2. Layout — macros compose components (vbox (:gap 1) box (hbox (:grow 1) input (make-select :options '((:title "Option A") (:title "Option B")))))

;; 3. Render — dispatches through the component protocol (render my-component backend)) ```

### Box

Bordered container. Draws borders using Unicode box-drawing characters (modern) or ASCII `+`/`-`/`|` (simple). Supports background fill, titled borders. See `org/box-renderable.org`.

```lisp (make-box &key (border-style :single) title (title-align :left) fg bg width height) ```

### Text

Styled text with inline spans and word wrapping. Spans support per-run attributes (bold, italic, underline, fg, bg). See `org/box-renderable.org`.

```lisp (make-text content &key fg bg wrap-mode width height spans) ;; Span example: (span "hello" :bold t :fg :bright-yellow) ```

### TextInput

Single-line text editor with emacs-style keybindings. Supports placeholder, max-length, on-submit callback. See `org/text-input.org`.

```lisp (make-text-input &key value cursor placeholder max-length on-submit) ;; Widget logic (input-level, no backend needed): (handle-text-input input (make-key-event :key :a :code (char-code #\a))) ```

### TextArea

Multi-line text editor. Supports undo/redo (Ctrl+Z/Y), cursor movement, line joining on backspace. See `org/text-input.org`.

```lisp (make-textarea &key value on-submit) ```

### ScrollBox

Scrollable viewport with a list of children. Only renders children intersecting the visible area (viewport culling). Scrollbars drawn at the right/bottom edges. See `org/scrollbox-tabbar.org`.

```lisp (make-scroll-box &key children scroll-y scroll-x sticky-scroll-p) (scroll-by sb dy dx) ```

### TabBar

Horizontal tab navigation. Renders tab labels, highlights active tab. Left/right arrows cycle through tabs. See `org/scrollbox-tabbar.org`.

```lisp (make-tab-bar &key tabs active) (tab-bar-add tb id title) (tab-bar-next tb) / (tab-bar-prev tb) (tab-bar-handle-key tb event) ```

### Select

Dropdown/filter widget. Options can have categories (rendered as non-selectable headers). Fuzzy fallback: matching > 30% character overlap. Arrow keys navigate, Enter selects. See `org/select.org`.

```lisp (make-select &key options filter on-select) ;; Options format: (:title "Name" :category "Group") or (:title "Name") ```

### Markdown

Parsed markdown AST with rendering. Supports headings, paragraphs, bold, italic, inline code, links, code blocks with syntax highlighting, diff blocks, blockquotes, lists, thematic breaks. See `org/markdown-renderer.org`.

```lisp (render-markdown "# Hello\n\nThis is bold.") ```

### Dialog + Toast

Modal dialog stack. `alert-dialog`, `confirm-dialog`, `select-dialog`, `prompt-dialog` are convenience constructors. Toasts are transient notifications that auto-dismiss. See `org/dialog.org`.

```lisp (push-dialog (make-instance 'dialog :size :medium)) (alert-dialog "Notice" "Operation complete") (toast "Saved!" :variant :success) ```

### Mouse

Mixin class providing mouse event handler slots. `hit-test` finds the deepest component at a coordinate. Text selection tracks drag gestures. Scrollboxes integrate wheel events. See `org/mouse.org`.

```lisp (defclass my-panel (mouse-mixin) …) (handle-mouse-event component mouse-event) (hit-test root x y) → deepest matching component ```

### Slot system

Plugin system for extensible rendering slots. Register named rendering functions, then render them by slot name. Useful for toolbars, status bars, and plugin architectures.

```lisp (defslot :status-bar :order 0 (lambda (&rest args) (draw-text backend 0 0 "Ready" :text-muted nil))) (slot-render :status-bar) ```

## Backend features

Feature modern simple
Truecolor (24-bit) Yes No
Bold/italic Yes No
OSC 8 hyperlinks Yes No
DECICM sync Yes No
SGR mouse Yes No
Kitty keyboard Yes No
Box drawing chars Unicode ASCII
Pipe-safe No Yes

Backend selection happens automatically via `detect-backend`. It checks:

  1. Is stdout a TTY? (if not → simple-backend)
  2. Does `COLORTERM` contain "truecolor" or "24bit"?
  3. Send DA1 query — does the terminal respond with modern feature codes?

Result is cached in `*detected-backend*`.

## Development

```bash

sbcl script run-all-tests.lisp

sbcl script demo.lisp

for f in org/*.org; do emacs batch eval "(progn (require 'org) (find-file \"$f\") (org-babel-tangle) (kill-buffer))" 2>&1 done ```

Literate programming: `.org` files in `org/` are the source of truth for the input system, scrollbox/tabbar, dialog, mouse, select, slot, framebuffer, and markdown modules. The backend (`modern.lisp`, `simple.lisp`) and basic components (`box.lisp`, `text.lisp`, `render.lisp`, `theme.lisp`, `dirty.lisp`) are written directly.

Project structure:

``` cl-tty/ ├── cl-tty.asd # ASDF system definition ├── demo.lisp # Interactive demo ├── run-all-tests.lisp # Test runner ├── backend/ # Backend protocol + implementations │ ├── package.lisp │ ├── classes.lisp # Generic definitions │ ├── simple.lisp # ASCII fallback backend │ ├── modern.lisp # Truecolor escape backend │ └── detection.lisp # Auto-detect backend from env ├── layout/ # Flexbox layout engine │ └── layout.lisp ├── src/ │ ├── rendering/ # Framebuffer backend + diff + flush │ │ └── framebuffer.lisp │ └── components/ # Widgets │ ├── box.lisp, text.lisp, render.lisp, theme.lisp │ ├── dirty.lisp, input-package.lisp, input.lisp │ ├── text-input.lisp, textarea.lisp, keybindings.lisp │ ├── scrollbox.lisp, tabbar.lisp, container-package.lisp │ ├── select.lisp, select-package.lisp │ ├── markdown.lisp, markdown-package.lisp │ ├── dialog.lisp, dialog-package.lisp │ ├── mouse.lisp, mouse-package.lisp │ └── slot.lisp, slot-package.lisp ├── tests/ # Test files ├── org/ # Literate source files │ ├── text-input.org │ ├── scrollbox-tabbar.org │ ├── dialog.org │ ├── mouse.org │ ├── select.org │ ├── slot.org │ ├── framebuffer.org │ ├── markdown-renderer.org │ ├── detection.org │ ├── modern-backend.org │ ├── box-renderable.org │ └── layout-engine.org └── docs/ ├── ROADMAP.org # Versioned roadmap └── ARCHITECTURE.org # Design docs ```

## License

GNU General Public License v3.0