Files
passepartout/harness/tui-client.org
Amr Gharbeia 41de20d3f1
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 11s
v0.2.1: polish, deploy, CI, and literate refactor
- Secret Exposure Gate + Privacy Filter (Bouncer)
- Shell actuator safety harness (timeout, blocked patterns)
- REPL-first enforcement (lisp validation gate, system-prompt-augment)
- Engineering Standards lifecycle (two-track Org-first + REPL-first)
- Literate Programming discipline (one function per block, reflect-back)
- AGENTS.md: thin routing layer, skills are authoritative
- SKILLS_DIR removed, ~/notes fallback eliminated
- opencortex.sh: multi-distro (Debian+Fedora), configure, install service, backup, restore, help
- infrastructure/opencortex.service (systemd user unit)
- Docker: updated to debian:trixie, fixed build context
- GitHub CI: lint + test workflows fixed, trigger on tags only
- Gitea CI: deploy workflow paths fixed
- README: one-line curl install, badges
- USER_MANUAL: Deployment section (bare metal, Docker, backup)
- .gitignore: skills/*.lisp and tests/*.lisp as generated artifacts
- Prose/block refactor across all 35 org files
- Test suite Tier 1: 43/45 pass (env-dependent failures isolated)
2026-05-02 17:04:33 -04:00

7.8 KiB

OpenCortex TUI Client (Standalone)

Overview

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 and a fixed input line at the bottom. Connected to the daemon at localhost:9105, it sends user input as framed protocol messages and displays responses as they arrive from the daemon's background reader thread.

Implementation

Package Context

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

Connection state

(defvar *daemon-host* "localhost")
(defvar *daemon-port* 9105)
(defvar *socket* nil)
(defvar *stream* nil)

UI state

(defvar *chat-history* nil)
(defvar *input-list* nil)
(defvar *is-running* t)

Thread-safe message queue

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

Utilities

(defun log-debug (msg &rest args)
  (ignore-errors
    (with-open-file (s "/tmp/opencortex-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))))

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

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

Rendering

(defun render-chat (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

(defun handle-backspace ()
  (pop *input-list*))

(defun handle-return (stream)
  (let ((cmd (coerce (reverse *input-list*) 'string)))
    (setf *input-list* 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

(defun start-background-reader (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)
                          (enqueue-msg "* Connected *"))
                         (t
                          (let ((text (or (getf payload :text) (format nil "~a" payload))))
                            (enqueue-msg (format nil "⬇ ~a" text))))))))
                 (sleep 0.05)))
         (error (c)
           (when *is-running*
             (log-debug "READER ERROR: ~a" c)
             (enqueue-msg "ERROR: Connection lost.")
             (setf *is-running* nil))))))
   :name "opencortex-tui-reader"))

Main Entry Point

(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)
          (start-background-reader *stream*)
          (loop :while *is-running* :do
            (let ((msgs (dequeue-msgs)))
              (when msgs 
                (dolist (m msgs) (push m *chat-history*))
                (render-chat 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))
                   (handle-return *stream*)
                   (render-chat chat-win chat-h))
                  ((or (eql ch 127) (eql ch 8) (eq ch :backspace) (eql ch #\Backspace))
                   (handle-backspace))
                  ((characterp ch)
                   (push ch *input-list*))
                  ((integerp ch)
                   (let ((converted (code-char ch)))
                     (when (graphic-char-p converted)
                       (push converted *input-list*))))))
              (clear input-win)
              (add-string input-win (format nil "▶ ~a" (coerce (reverse *input-list*) '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/opencortex" \
    -e OC_DATA_DIR="$HOME/.local/share/opencortex" \
    -e TERM="screen-256color" \
    "sbcl --non-interactive \
        --eval '(load (merge-pathnames \"quicklisp/setup.lisp\" (user-homedir-pathname)))' \
        --eval '(push (truename \"$HOME/.local/share/opencortex/\") asdf:*central-registry*)' \
        --eval '(ql:quickload :opencortex/tui)' \
        --eval '(opencortex.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