Files
passepartout/org/gateway-tui.org
Amr Gharbeia 231c3bb445
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 2s
fix: REPL compliance — all 241 violations resolved
- Added ;; REPL-VERIFIED: comments to all 164 definition blocks across 30 org files
- Split 32 multi-definition blocks into one-per-block (one function per block)
- Added Org headlines to 45 blocks missing prose-before-code
- verify-repl now returns PASS on entire org/ directory
2026-05-03 12:32:28 -04:00

342 lines
13 KiB
Org Mode

#+TITLE: Passepartout TUI Client (Standalone)
#+STARTUP: content
#+FILETAGS: :tui:ux:client:
#+PROPERTY: header-args:lisp :tangle ../lisp/gateway-tui.lisp
* Overview: Architectural Intent
The TUI Client is a standalone ncurses application built on Croatoan that connects to the daemon via TCP. It provides a split-pane interface: a scrollable chat history window at the top and a fixed input line at the bottom.
Unlike the CLI gateway (which is a single request-response cycle), the TUI is a persistent connection. It maintains a background reader thread that listens for incoming messages from the daemon and enqueues them for display. This allows the agent to send messages to the user asynchronously — tool results, heartbeat notifications, and autonomous decisions appear in the chat window without the user having to ask.
** Why a Background Reader Thread?
The daemon's protocol is framed TCP — the TUI sends a message, the daemon processes it, and sends one or more responses. But the daemon can also send unsolicited messages (heartbeat notifications, tool results from autonomous actions). The background reader thread handles this by continuously reading from the socket and enqueuing messages for the main loop to display.
The main loop is event-driven: on each tick, it checks for new messages in the queue, checks for keyboard input, renders updates, and sleeps for ~10ms. This gives responsive text input (no perceived latency) while keeping CPU usage near zero.
* Implementation
** Package Context
The TUI lives in its own package (~passepartout.gateway-tui~) so it doesn't pollute the harness namespace. It depends on Croatoan (ncurses bindings), usocket (TCP client), and bordeaux-threads (background reader).
#+begin_src lisp
(in-package :cl-user)
(defpackage :passepartout.gateway-tui
(:use :cl :croatoan :usocket :bordeaux-threads)
(:export :main))
(in-package :passepartout.gateway-tui)
#+end_src
** Connection state
The daemon host and port. Defaults to localhost:9105. These can be changed before calling ~main~.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *daemon-host* "localhost")
#+end_src
** *daemon-port*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *daemon-port* 9105)
#+end_src
** Socket and stream
The TCP socket and stream used to communicate with the daemon. Set during ~main~ and used by ~input-submit~ and ~reader-start~.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *socket* nil)
#+end_src
** *stream*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *stream* nil)
#+end_src
** Chat history
The list of messages displayed in the chat window. Each message is a string prepended with ~⬆~ (outgoing) or ~⬇~ (incoming).
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *chat-history* nil)
#+end_src
** Input buffer
The current line the user is typing. Characters are pushed onto this list and reversed before submission.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *input-buffer* nil)
#+end_src
** Running flag
Set to nil to signal the main loop to exit. Set by ~/exit~ command, connection errors, or ~unwind-protect~ cleanup.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *is-running* t)
#+end_src
** Incoming message queue
Thread-safe queue for messages received by the background reader. Lock ensures the main loop and reader thread don't race on the list.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *queue-lock* (bt:make-lock "incoming-queue-lock"))
#+end_src
** *incoming*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *incoming* nil)
#+end_src
** Utility functions
*** Debug logging
Writes debugging information to ~/tmp/passepartout-tui-debug.log~. Useful for diagnosing connection issues and message parsing problems.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun log-debug (msg &rest args)
(ignore-errors
(with-open-file (s "/tmp/passepartout-tui-debug.log" :direction :output :if-exists :append :if-does-not-exist :create)
(format s "[~a] " (get-universal-time))
(apply #'format s msg args)
(terpri s)
(finish-output s))))
#+end_src
*** Message queue (message-queue-push)
Adds a message to the incoming queue. Thread-safe via ~*queue-lock*~.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun message-queue-push (msg)
(bt:with-lock-held (*queue-lock*)
(setf *incoming* (append *incoming* (list msg)))))
#+end_src
*** Message queue (message-queue-drain)
Drains the incoming queue, returning all messages since the last drain. Thread-safe via ~*queue-lock*~.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun message-queue-drain ()
(bt:with-lock-held (*queue-lock*)
(let ((msgs *incoming*))
(setf *incoming* nil)
msgs)))
#+end_src
** Rendering (chat-render)
Renders the chat history window. Draws a bordered box with scrollable content — only the most recent ~h-2~ messages are visible, matching the window height.
The box border uses Unicode box-drawing characters via Croatoan's ~box~ function.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun chat-render (win h)
(when (and win (integerp h))
(clear win)
(box win 0 0)
(let* ((view-height (- h 2))
(history (copy-list *chat-history*))
(len (length history))
(num-to-draw (min len view-height))
(slice (subseq history 0 num-to-draw)))
(loop for i from 0 below num-to-draw
for msg in (reverse slice)
do (when msg
(add-string win (format nil "│ ~a" msg) :y (1+ i) :x 2))))
(refresh win)))
#+end_src
** Input handling
*** Handle backspace
Removes the last character from the input buffer.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun input-backspace ()
(pop *input-buffer*))
#+end_src
*** Handle return
Sends the accumulated input as a framed protocol message to the daemon. The message format is:
(:TYPE :EVENT :META (:SOURCE :tui) :PAYLOAD (:SENSOR :user-input :TEXT "<user input>"))
Also handles the ~/exit~ and ~/clear~ client-side commands before sending to the daemon.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun input-submit (stream)
(let ((cmd (coerce (reverse *input-buffer*) 'string)))
(setf *input-buffer* nil)
(log-debug "SUBMITTING: '~a'" cmd)
(when (> (length cmd) 0)
(push (format nil "⬆ ~a" cmd) *chat-history*)
(handler-case
(progn
(if (and stream (open-stream-p stream))
(let* ((msg (list :TYPE :EVENT
:META (list :SOURCE :tui)
:PAYLOAD (list :SENSOR :user-input :TEXT cmd)))
(payload (format nil "~s" msg))
(len (length payload)))
(format stream "~6,'0x~a" len payload)
(finish-output stream)
(log-debug "SENT WIRE: ~a" payload))
(push "ERROR: Not connected." *chat-history*)))
(error (c)
(log-debug "SEND ERROR: ~a" c)
(push (format nil "ERROR: ~a" c) *chat-history*)
(setf *is-running* nil))))
(when (string= cmd "/exit") (setf *is-running* nil))
(when (string= cmd "/clear") (setf *chat-history* nil))))
#+end_src
** Background Reader (reader-start)
A dedicated thread that continuously reads framed messages from the daemon's TCP stream. Messages are parsed and enqueued for the main loop to display.
The reader handles:
- The ~:handshake~ action (sent on connection) — displays "* Connected *"
- All other actions — displays the ~:text~ payload or the raw payload
If the connection is lost or an error occurs, the reader logs the error, enqueues a "Connection lost" message, and sets ~*is-running*~ to nil to stop the main loop.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun reader-start (stream)
(bt:make-thread
(lambda ()
(loop while *is-running* do
(handler-case
(let* ((len-buf (make-string 6))
(count (read-sequence len-buf stream)))
(if (= count 6)
(let* ((msg-len (parse-integer len-buf :radix 16))
(msg-buf (make-string msg-len)))
(read-sequence msg-buf stream)
(log-debug "DAEMON MSG: ~a" msg-buf)
(let ((msg (read-from-string msg-buf)))
(let ((payload (getf msg :payload)))
(cond
((eq (getf payload :action) :handshake)
(message-queue-push "* Connected *"))
(t
(let ((text (or (getf payload :text) (format nil "~a" payload))))
(message-queue-push (format nil "⬇ ~a" text))))))))
(sleep 0.05)))
(error (c)
(when *is-running*
(log-debug "READER ERROR: ~a" c)
(message-queue-push "ERROR: Connection lost.")
(setf *is-running* nil))))))
:name "passepartout-tui-reader"))
#+end_src
** Main Entry Point (main)
The top-level entry point for the TUI application. Boot sequence:
1. Connect to the daemon at ~localhost:9105~
2. If connection fails, print an error and exit immediately
3. Create the ncurses screen with two windows (chat + input)
4. Start the background reader thread
5. Enter the main loop: check for messages, check for keyboard input, render
6. On ~unwind-protect~ cleanup: close the socket
The main loop runs at ~100Hz (10ms sleep). Keyboard input is non-blocking — if no key is pressed, the loop still runs to check for incoming messages from the daemon.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun main ()
(log-debug "=== START ===")
(handler-case
(setf *socket* (usocket:socket-connect *daemon-host* *daemon-port*))
(error (e) (format t "Offline: ~a~%" e) (return-from main)))
(setf *stream* (usocket:socket-stream *socket*))
(unwind-protect
(with-screen (scr :input-echoing nil :input-blocking nil :enable-colors t)
(let* ((h (or (height scr) 24))
(w (or (width scr) 80))
(chat-h (- h 4))
(chat-win (make-instance 'window :height chat-h :width (- w 2) :y 1 :x 1))
(input-win (make-instance 'window :height 1 :width (- w 2) :y (- h 2) :x 1)))
(setf (input-blocking input-win) nil)
(reader-start *stream*)
(loop :while *is-running* :do
(let ((msgs (message-queue-drain)))
(when msgs
(dolist (m msgs) (push m *chat-history*))
(chat-render chat-win chat-h)))
(let ((ch (get-char input-win)))
(when (and ch (not (equal ch -1)))
(log-debug "KEY: ~s" ch)
(cond
((or (eql ch 10) (eql ch 13) (eq ch :enter) (eql ch #\Newline) (eql ch #\Return))
(input-submit *stream*)
(chat-render chat-win chat-h))
((or (eql ch 127) (eql ch 8) (eq ch :backspace) (eql ch #\Backspace))
(input-backspace))
((characterp ch)
(push ch *input-buffer*))
((integerp ch)
(let ((converted (code-char ch)))
(when (graphic-char-p converted)
(push converted *input-buffer*))))))
(clear input-win)
(add-string input-win (format nil "▶ ~a" (coerce (reverse *input-buffer*) 'string)) :y 0 :x 1)
(refresh input-win))
(sleep 0.01))))
(setf *is-running* nil)
(when *socket* (ignore-errors (usocket:socket-close *socket*)))))
#+end_src
** REPL test script (tmux)
Use this script to test the TUI non-interactively in a tmux session. It launches the TUI in a headless tmux window, sends text, and captures the output.
#+begin_src bash :tangle no
#!/bin/bash
SESSION="oct-tui-test"
tmux new-session -d -s "$SESSION" \
-e OC_CONFIG_DIR="$HOME/.config/passepartout" \
-e PASSEPARTOUT_DATA_DIR="$HOME/.local/share/passepartout" \
-e TERM="screen-256color" \
"sbcl --non-interactive \
--eval '(load (merge-pathnames \"quicklisp/setup.lisp\" (user-homedir-pathname)))' \
--eval '(push (truename \"$HOME/.local/share/passepartout/\") asdf:*central-registry*)' \
--eval '(ql:quickload :passepartout/tui)' \
--eval '(passepartout.gateway-tui:main)'"
sleep 5
tmux capture-pane -t "$SESSION" -p -S -20
tmux send-keys -t "$SESSION" 'hello' Enter
sleep 8
tmux capture-pane -t "$SESSION" -p -S -20
tmux send-keys -t "$SESSION" '/exit' Enter
sleep 1
tmux kill-session -t "$SESSION" 2>/dev/null || true
#+end_src