#+TITLE: cl-tty TUI Migration Plan #+AUTHOR: Hermes #+DATE: 2026-05-12 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: #+BEGIN_SRC lisp (with-screen (scr :input-blocking nil :input-echoing nil :cursor-visible nil) ...) #+END_SRC With: #+BEGIN_SRC lisp (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)))) #+END_SRC **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-string~ → ~cl-tty.backend:draw-text~ ~box~ → ~cl-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~: #+BEGIN_SRC lisp :resize-pending nil #+END_SRC Add a SIGWINCH handler: #+BEGIN_SRC lisp (sb-sys:enable-interrupt sb-posix:sigwinch (lambda () (setf (st :resize-pending) t))) #+END_SRC In the main loop, check before rendering: #+BEGIN_SRC lisp (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))) #+END_SRC **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: #+BEGIN_SRC lisp (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")))))) #+END_SRC 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.)