Files
passepartout/org/gateway-tui.org
Amr Gharbeia 95d1ea3fed feat: add DeepSeek and NVIDIA NIM providers
- Add deepseek and nvidia entries to gateway-provider config

- Add DEEPSEEK_API_KEY and NVIDIA_API_KEY to .env.example

- Add deepseek and nvidia to doctor's LLM provider check

- Fix remaining harness-log → log-message reference
2026-05-02 22:25:24 -04:00

12 KiB

Passepartout TUI Client (Standalone)

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

(in-package :cl-user)
(defpackage :passepartout.gateway-tui
  (:use :cl :croatoan :usocket :bordeaux-threads)
  (:export :main))
(in-package :passepartout.gateway-tui)

Connection state

The daemon host and port. Defaults to localhost:9105. These can be changed before calling main.

(defvar *daemon-host* "localhost")
(defvar *daemon-port* 9105)

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.

(defvar *socket* nil)
(defvar *stream* nil)

Chat history

The list of messages displayed in the chat window. Each message is a string prepended with (outgoing) or (incoming).

(defvar *chat-history* nil)

Input buffer

The current line the user is typing. Characters are pushed onto this list and reversed before submission.

(defvar *input-buffer* nil)

Running flag

Set to nil to signal the main loop to exit. Set by /exit command, connection errors, or unwind-protect cleanup.

(defvar *is-running* t)

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.

(defvar *queue-lock* (bt:make-lock "incoming-queue-lock"))
(defvar *incoming* nil)

Utility functions

Debug logging

Writes debugging information to /tmp/passepartout-tui-debug.log. Useful for diagnosing connection issues and message parsing problems.

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

Message queue (message-queue-push)

Adds a message to the incoming queue. Thread-safe via *queue-lock*.

(defun message-queue-push (msg)
  (bt:with-lock-held (*queue-lock*)
    (setf *incoming* (append *incoming* (list msg)))))

Message queue (message-queue-drain)

Drains the incoming queue, returning all messages since the last drain. Thread-safe via *queue-lock*.

(defun message-queue-drain ()
  (bt:with-lock-held (*queue-lock*)
    (let ((msgs *incoming*))
      (setf *incoming* nil)
      msgs)))

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.

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

Input handling

Handle backspace

Removes the last character from the input buffer.

(defun input-backspace ()
  (pop *input-buffer*))

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.

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

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.

(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"))

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.

(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*)))))

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.

#!/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