Files
cl-tty/.hermes/plans/2026-05-12-cl-tty-bug-fixes.md

10 KiB

cl-tty v1.0.0 Bug Fix Iteration

For Hermes: Use subagent-driven-development + bug-fix-iteration pattern. Each task: inspect → write regression test → fix → verify → commit. Do NOT skip tests. Do NOT combine tasks.

Goal: Fix all known bugs and blindspots before v1.0.0 release.

Architecture: cl-tty is a pure CL terminal UI library. No FFI, no ncurses. Components: backend (modern/simple escape seq), input (byte reader + event parser), rendering (framebuffer diff pipeline), layout (flexbox), widgets.

Verification command after each fix:

cd /mnt/hermes/projects/cl-tty && sbcl --script run-all-tests.lisp && python3 scripts/verify-api.py && python3 scripts/verify-demo-pty.py

Task 1: Fix read-raw-byte timeout (CRITICAL BUG)

Objective: The timeout mechanism uses get-universal-time which returns integer seconds. Adding a float timeout like 0.05 produces a deadline that equals the current second — the loop terminates immediately. The 50ms escape ambiguity timeout never actually works.

Files:

  • Modify: src/components/input.lisp:84-111
  • Test: tests/input-tests.lisp (add regression test)

Root cause: Line 99: (let ((deadline (+ (get-universal-time) timeout)))get-universal-time returns integer seconds, so (+ (integer) 0.05) = (+ integer 0) = integer. The loop (while (< (get-universal-time) deadline)) runs zero iterations for any sub-second timeout.

Fix: Use sb-ext:get-time-of-day (microsecond precision) or (/ (get-internal-real-time) internal-time-units-per-second) to get fractional seconds. Replace:

(let ((deadline (+ (get-universal-time) timeout)))
  (loop while (< (get-universal-time) deadline) ...))

with:

(let* ((start (get-internal-real-time))
       (ticks (round (* timeout internal-time-units-per-second)))
       (deadline (+ start ticks)))
  (loop while (< (get-internal-real-time) deadline) ...))

Or simpler: use (/ (- (get-internal-real-time) start) internal-time-units-per-second) to check elapsed time in a loop.

Verification:

  1. Write a test that calls read-raw-byte with :timeout 0.05 and verifies it returns (values nil :timeout) within ~100ms (not instantly).
  2. All existing tests still pass.
  3. The demo's Escape key works (tested by verify-demo-pty.py).

Task 2: Fix draw-border ignoring title in modern backend (BUG)

Objective: The modern-backend's draw-border method has (declare (ignore title title-align)) on line 194. The framebuffer backend renders titles correctly. The simple backend also ignores titles. This means titled borders don't show titles in the modern backend.

Files:

  • Modify: backend/modern.lisp:192-219
  • Add test: backend/modern-tests.lisp

Fix: In draw-border for modern-backend, insert the title text into the top border line after the first character. The title should be centered or left-aligned based on title-align.

The title rendering logic should extract from the framebuffer backend's draw-border (framebuffer.lisp lines 114-117) and adapt for escape sequences:

  • The top border line is constructed as: tl + h*N + tr
  • Before writing top: if title is non-nil, insert it: tl + " " + title + " " + h*fill + tr
  • Truncate title if it exceeds width-4

Task 3: Fix backend-size to query real terminal size (MISSING FEATURE)

Objective: backend-size for modern-backend returns hardcoded (80 24). Should query the terminal via TIOCGWINSZ ioctl or ESC[18t query.

Files:

  • Modify: backend/modern.lisp:163-165
  • Add test: backend/modern-tests.lisp (test that values are positive integers)

Fix: Use SBCL's sb-alien to call ioctl with TIOCGWINSZ on the stdout fd (or /dev/tty):

(defmethod backend-size ((b modern-backend))
  (sb-unix:unix-ioctl (sb-sys:fd-stream-fd
                        (or (ignore-errors
                              (open "/dev/tty" :direction :input
                                    :if-does-not-exist nil))
                            *standard-output*))
                      sb-unix:TIOCGWINSZ ...)
  ;; Or fallback to query-terminal with ESC[18t
  ;; Fallback: (values 80 24))

Simpler approach: Use sb-unix:unix-ioctl with the TIOCGWINSZ request. The winsize struct is: (rows columns) as two 16-bit values. In SBCL, sb-unix:unix-ioctl can be used with sb-unix:TIOCGWINSZ.

If ioctl is complex, implement via OSC Terminal query: query-terminal with ESC[18t returns ESC[8;rows;colst. Parse the response.


Task 4: Enable kitty keyboard protocol in initialize-backend (MISSING FEATURE)

Objective: modern-backend declares :kitty-keyboard in capable-p but never sends the escape sequence to enable it (ESC[?u).

Files:

  • Modify: backend/modern.lisp:142-151

Fix: Add to initialize-backend:

(backend-write b (format nil "~C[?u" #\Esc))  ; kitty keyboard

And add to shutdown-backend:

(backend-write b (format nil "~C[?u" #\Esc))  ; restore default keyboard

Task 5: Fix text-input cursor rendering (MISSING VISUAL FEEDBACK)

Objective: The text-input.lisp render method declares (declare (ignore cursor)). The cursor position is tracked but never drawn, so users can't see where they're typing.

Files:

  • Modify: src/components/text-input.lisp (render method)
  • Add test: tests/input-tests.lisp or existing test file

Fix: In the text-input render method, after drawing the value/placeholder, draw a cursor block (█ or reversed ▓) at the cursor position. Use draw-rect or draw-text with a visual cursor character at the cursor column.

When the cursor would be beyond the visible area (scrolled past the right edge), show it at the rightmost position.


Task 6: Fix SS3 branch reading without timeout (POTENTIAL HANG)

Objective: In %read-escape-sequence, the SS3 branch (when b=#x4f) calls (read-raw-byte) without a timeout parameter. If the terminal sends a partial ESC O with no follow-up byte, the read blocks forever.

Files:

  • Modify: src/components/input.lisp:210

Fix: Change line 210 from:

(let ((b2 (read-raw-byte)))

to:

(let ((b2 (read-raw-byte :timeout 0.1)))

And handle the nil case: if b2 is nil, return a key-event for the lone Escape.


Task 7: Add Wayland support to copy-to-clipboard (PLATFORM GAP)

Objective: copy-to-clipboard in mouse.lisp only supports X11 (xclip) and macOS (pbcopy). Wayland users (wl-copy) get no clipboard.

Files:

  • Modify: src/components/mouse.lisp:51-54

Fix: Add #+wayland or detect Wayland via $WAYLAND_DISPLAY env var:

(defun copy-to-clipboard (text)
  #+linux
  (cond
    ((sb-ext:posix-getenv "WAYLAND_DISPLAY")
     (sb-ext:run-program "wl-copy" nil :input text :wait nil))
    (t
     (sb-ext:run-program "xclip" (list "-selection" "clipboard")
                         :input text :wait nil)))
  #+darwin
  (sb-ext:run-program "pbcopy" nil :input text :wait nil))

Task 8: Add SIGWINCH handler for terminal resize (MISSING FEATURE)

Objective: When the terminal is resized, the demo and any cl-tty app will render with stale dimensions. The backend-size (Task 3) helps but apps need to be notified of resizes.

Files:

  • Create: src/components/notification.lisp OR modify existing components

Approach: This is a design decision. Options: a) Install a SIGWINCH handler that sets a flag checked each frame b) Provide a register-resize-callback API c) Only fix in the demo layer (demo.lisp)

Keep it minimal: install a simple signal handler that sets *terminal-resized-p* to T. The app checks this flag each frame.

Add to input.lisp or a new file:

(defvar *terminal-resized-p* nil
  "Set to T by SIGWINCH handler when terminal resizes.")

(defun %handle-sigwinch (signal info context)
  (declare (ignore signal info context))
  (setf *terminal-resized-p* t))

;; Install handler
#+sbcl
(sb-sys:enable-interrupt sb-unix:sigwinch #'%handle-sigwinch)

Bug Blindspots Verified as NOT Bugs (justifying "won't fix"):

These were investigated and are fine:

  • Framebuffer diff link-url: cells-equal-p compares cell-link-url with equal — covered.
  • Select with empty options: (if (zerop count) (setf (select-selected-index sel) 0) — handled.
  • Dialog pop from empty stack: (when *dialog-stack* — guarded.
  • parse-csi-params: reads raw bytes, handles EOF gracefully.
  • Thread safety of globals: out of scope for v1.0.0 (single-threaded TUI).
  • ScrollBox horizontal scrolling: actually implemented (uses sx in render).
  • Redundant tests removed: cleanup already done in uncommitted diff.

BLINDSPOT: The parse-csi-params function also uses (read-raw-byte) without timeout.

Line 122: (multiple-value-bind (b reason) (read-raw-byte) — while parsing a CSI sequence, if the terminal sends ESC[ but never completes the sequence, this blocks forever. This should use a timeout similar to the escape sequence reader. Same fix pattern as Task 6.

Adding as Task 9.


Task 9: Fix parse-csi-params to use timeout (POTENTIAL HANG)

Objective: parse-csi-params (input.lisp line 122) reads bytes without timeout. A partial CSI sequence (ESC[ without final byte) blocks forever.

Files:

  • Modify: src/components/input.lisp:116-149

Fix: Add a timeout to the read inside parse-csi-params. Use a total timeout of ~500ms for the entire CSI sequence (generous given terminals respond within a few ms). If the timeout fires, return nil for final-byte.

Similar to %read-escape-sequence, pass :timeout parameter to parse-csi-params and have %read-escape-sequence pass a timeout to it.


Task 10: Fix draw-border ignoring title in simple backend (BUG)

Objective: Same as Task 2 but for simple-backend. The %simple-border-char function just got refactored (uncommitted diff), and draw-border in simple.lisp also ignores title.

Files:

  • Modify: backend/simple.lisp (draw-border method)
  • Add test: backend/tests.lisp

Fix: In simple-backend's draw-border, when a title is provided, insert it into the top border line. Use ASCII chars (the simple backend doesn't use Unicode).


Task 11: Add detect-backend export to backend package (API GAP)

Objective: The README shows (cl-tty.backend:detect-backend) as the entry point, but verify this is actually exported from the backend package.

Files:

  • Check: backend/package.lisp

Fix: Ensure #:detect-backend is in the package's :export list.