Hermes Agent f50d0e61d1 literate: convert org/box-renderable.org from doc-only to tangle source
Now tangles to box.lisp + text.lisp + box-tests.lisp.
Deleted hand-written originals and regenerated — GREEN.
2026-05-12 17:16:26 +00:00

cl-tty — Terminal UI Framework for Common Lisp

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

(ql:quickload :cl-tty)

Quick start

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

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

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:

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

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

;; 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 src/layout/layout.lisp or org/layout-engine.org for the full API.

Rendering pipeline

Component trees render through a coordinated pipeline:

  1. Layout passcompute-layout traverses dirty branches, solves flex constraints
  2. Render dispatchrender 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
;; 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:

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

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

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

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

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

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

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

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

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

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

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

(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

# Run all tests (483 checks, 13 suites)
sbcl --script run-all-tests.lisp

# Run interactive demo
sbcl --script demo.lisp

# Tangle org files (regenerate .lisp from .org sources)
python3 ~/.hermes/skills/software-development/org-babel-tangle/scripts/tangle.py org/*.org

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
├── src/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
├── src/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

Description
Reusable Common Lisp Terminal UI Framework
Readme GPL-3.0 1.8 MiB
Languages
Common Lisp 58.5%
Python 36.2%
Shell 5.3%