Files
passepartout/docs/cl-tty-migration-plan.org
Hermes 757541c83b fix: close defun on-key with missing paren, complete cl-tty TUI migration
- Added missing closing paren for defun on-key in org/channel-tui-main.org
  line 616 (was 7 trailing ), now 8)
- Replaced #\) character literal with (code-char 41) to avoid reader
  ambiguity with paren-delimiter counting
- All 3 TUI org files tangled and verified compilable
- passepartout/tui loads without errors under SBCL 2.5.2
2026-05-12 21:35:14 +00:00

9.3 KiB

cl-tty TUI Migration Plan

Croatoan is not working and passepartout's TUI needs a reliable rendering backend. cl-tty was built for exactly this use case. This plan details the migration from Croatoan (ncurses via CFFI) to cl-tty (pure CL, no FFI).

Current Architecture (Croatoan)

3 org files, ~2K LOC total:

  • state.org (191 lines): state plist, theme presets, event queue, helpers
  • main.org (1370 lines): key dispatch, daemon protocol, main loop
  • view.org (480 lines): render functions, markdown rendering, gate trace

Croatoan API calls used:

Croatoan call Purpose Count
with-screen Terminal init (raw, no echo) 1
make-instance 'window Window creation for layout ~10
add-string Render text w/ fg, bg, attrs ~20
get-char Read keypress 1
code-key~/~key-name Convert raw code → keyword 2
clear Clear window contents 3
refresh Flush window to terminal ~8
box Draw border around window 2
width~/~height Query window dimensions ~6
(setf cursor-position) Set cursor location 1
function-keys-enabled-p Enable function key codes 2
input-blocking Non-blocking input mode 2

Migration Strategy: Option C (Hybrid)

Replace the rendering backend only. Keep passepartout's application logic (state machine, event handlers, daemon protocol, markdown parser) intact.

Don't rewrite the event handling into cl-tty's component/keymap system. Don't replace the state plist with cl-tty components. Replace Croatoan window operations with cl-tty backend primitives.

Why not pure component tree (Option B):

The 1370-line event handler in main.org is deeply coupled to the plist state model. Untangling it into cl-tty component event handlers would be churn with no user-visible benefit. The markdown renderer, gate trace, search mode, HITL panels, streaming text, and undo/redo are all app-specific logic that cl-tty doesn't need to know about. Keep them as-is, just swap the output path.

Step-by-step Plan

Step 1: Add cl-tty dependency (5 min)

  • Add :cl-tty to passepartout/tui system dependencies in .asd
  • Remove :croatoan dependency
  • Add cl-tty to Quicklisp/local-projects or install path

Step 2: Replace with-screen with cl-tty init (30 min)

Replace:

(with-screen (scr :input-blocking nil :input-echoing nil :cursor-visible nil)
  ...)

With:

(sb-posix:with-raw-terminal
  (let* ((be (cl-tty.backend:detect-backend))
         (w (nth-value 0 (cl-tty.backend:backend-size be)))
         (h (nth-value 1 (cl-tty.backend:backend-size be))))
    (cl-tty.backend:initialize-backend be)
    (unwind-protect
         (tui-loop be w h)
      (cl-tty.backend:shutdown-backend be))))

Step 3: Replace windows with cl-tty rendering (view.org, 2-3 hours)

Replace Croatoan window operations in view-status, view-chat, view-input:

add-stringcl-tty.backend:draw-text boxcl-tty.backend:draw-border clear → framebuffer clear or cl-tty.backend:backend-clear refresh → framebuffer flush (flush-framebuffer)

Each render function (view-status, view-chat, view-input) takes:

  • cl-tty backend instance (instead of Croatoan window)
  • x/y/w/h region (instead of width~/~height on window)

Step 4: Wire framebuffer diffing (view.org + main.org, 1 hour)

Replace per-window clear~+~refresh with cl-tty's framebuffer:

  1. Create framebuffer at terminal size
  2. Each render function draws render commands into the framebuffer
  3. Main loop calls flush-framebuffer which diffs and writes only changed cells

The existing dirty-flag system ((st :dirty) as (list status chat input)) maps naturally: each dirty flag maps to which regions of the framebuffer need rebuilding.

Step 5: Replace input handling (main.org, 1 hour)

Replace get-char + code-key~/~key-name conversion with cl-tty.input:read-event:

  • read-event returns structured key-event structs with :key and :modifiers
  • No manual integer → keyword conversion needed
  • Arrow keys, Enter, Backspace, Tab, PageUp/Down all come as keywords
  • Ctrl+letter codes come as (make-key-event :key 'a :ctrl t)

Key mapping table:

Croatoan code Current convert cl-tty event
263/127/8 :backspace (key :backspace)
259 :up (key :up)
258 :down (key :down)
260 :left (key :left)
261 :right (key :right)
339 :ppage (key :page-up)
338 :npage (key :page-down)
13/10 :enter (key :enter)
9 :tab (key :tab)
27 27 (key :escape)
410 KEY_RESIZE (needs signal handler)
21 (C-u) 21 (key #\u :ctrl t)
1 (C-a) 1 (key #\a :ctrl t)
5 (C-e) 5 (key #\e :ctrl t)

Replace the cond dispatcher in on-key: change integer checks to keyword comparisons. The logic stays identical — only the key representation changes.

Step 6: Handle SIGWINCH (main.org, 30 min)

cl-tty doesn't have built-in resize handling. Add a sb-sys:with-deadline or SIGWINCH handler that sets a resize-pending flag. The main loop checks this flag and calls backend-size to get new dimensions, then marks all dirty flags.

Add to init-state:

:resize-pending nil

Add a SIGWINCH handler:

(sb-sys:enable-interrupt sb-posix:sigwinch
  (lambda () (setf (st :resize-pending) t)))

In the main loop, check before rendering:

(when (st :resize-pending)
  (setf (st :resize-pending) nil)
  (multiple-value-setq (w h) (cl-tty.backend:backend-size be))
  (setf (st :dirty) (list t t t)))

Step 7: Tone down to 10fps (main.org, 5 min)

The current 30fps ((sleep 0.03)) is overkill for a chat UI. Change to (sleep 0.1) for 10fps. The framebuffer only sends changes — idle frames cost nothing.

Step 8: Map theme colors (state.org, 30 min)

passepartout has 27 semantic theme keys. Croatoan uses keyword colors (:green, :red, :cyan, :yellow, :magenta, :blue, :white, :black) while cl-tty uses hex strings ("#00FF00") for truecolor or named colors.

Solution: keep passepartout's *tui-theme* plist as-is. Change theme-color to return hex strings compatible with cl-tty:

(defun theme-color-to-hex (role)
  (let ((val (getf *tui-theme* role)))
    (cond
      ((stringp val) val)  ; already hex like "#ebdbb2"
      ((keywordp val)      ; named Croatoan color → hex
       (case val
         (:green "#00FF00") (:red "#FF0000") (:cyan "#00FFFF")
         (:yellow "#FFFF00") (:magenta "#FF00FF") (:blue "#0000FF")
         (:white "#FFFFFF") (:black "#000000")
         (t "#FFFFFF"))))))

The gruvbox and solarized presets already use hex strings — they work directly with cl-tty. Only the dark and light presets use Croatoan keywords and need mapping.

Step 9: Remove Croatoan TUI system (5 min)

The passepartout/tui system no longer needs :croatoan. Update the ASDF definition.

What cl-tty Gains From This

This is the litmus test for cl-tty. If it can serve as the rendering backend for a real application, it validates the architecture. Specific needs that would drive cl-tty improvements:

  1. SIGWINCH handling — cl-tty should provide a with-resize-handler macro or similar. Currently the application has to set this up manually.
  2. Framebuffer coordinate management — the framebuffer API needs to support partial region updates (the passepartout dirty flags map to specific areas: status bar rows 0-2, chat rows 3 to h-2, input row h-1).
  3. Non-blocking read-event — already supported via :timeout keyword but should be documented as the main loop pattern.

Files to Modify

File Change
passepartout.asd Add :cl-tty, remove :croatoan
org/channel-tui-state.org Package uses, theme-color returns hex
org/channel-tui-main.org Replace main loop, input handling
org/channel-tui-view.org Replace all Croatoan window ops

Verification

After each step, the TUI should:

  1. Compile without Croatoan dependency
  2. Start and show status bar, empty chat, input line
  3. Accept keyboard input and display typed text
  4. Connect to daemon and show messages
  5. Support all keybindings (arrows, Ctrl, Tab, PageUp/Down)
  6. Support resize via SIGWINCH
  7. Render markdown (bold, code, URLs, code blocks)
  8. Show gate traces with collapsible toggle
  9. All view and markdown tests pass (test-char-width, parse-markdown-spans, etc.)