Files
passepartout/harness/tui-client.org

5.6 KiB

OpenCortex TUI Client (Standalone)

Overview

The OpenCortex TUI Client is a standalone Common Lisp application built on Croatoan.

Test Suite

(defpackage :opencortex-tui-tests
  (:use :cl :opencortex)
  (:export #:tui-suite))

(in-package :opencortex-tui-tests)

(eval-when (:compile-toplevel :load-toplevel :execute)
  (ql:quickload :fiveam :silent t))

(fiveam:def-suite tui-suite :description "Verification of the TUI parsing and styling logic")
(fiveam:in-suite tui-suite)

(fiveam:test test-tui-connection-drop
  "Tier 2 Chaos: Verify that handle-return degrades gracefully when the daemon connection is lost."
  (let ((opencortex.tui::*incoming-msgs* nil)
        (opencortex.tui::*input-buffer* (make-array 5 :element-type 'char :initial-contents "hello" :fill-pointer 5 :adjustable t))
        ;; Create a closed stream to simulate connection drop
        (mock-stream (make-string-output-stream)))
    (close mock-stream)
    (opencortex.tui::handle-return mock-stream)
    ;; Check if the error was enqueued to history instead of crashing
    (fiveam:is (member "ERROR: Connection to daemon lost." opencortex.tui::*incoming-msgs* :test #'string=))))

Implementation

Package Context

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

Global State

(defvar *daemon-host* "127.0.0.1")
(defvar *daemon-port* 9105)
(defvar *socket* nil)
(defvar *stream* nil)
(defvar *chat-history* nil)
(defvar *scroll-index* 0)
(defvar *input-buffer* (make-array 0 :element-type 'char :fill-pointer 0 :adjustable t))
(defvar *is-running* t)
(defvar *queue-lock* (bt:make-lock))
(defvar *incoming-msgs* nil)

Utilities

(defun enqueue-msg (msg)
  "Thread-safe addition to incoming message queue."
  (bt:with-lock-held (*queue-lock*)
    (setf *incoming-msgs* (append *incoming-msgs* (list msg)))))

(defun dequeue-msgs ()
  "Thread-safe retrieval of incoming messages."
  (bt:with-lock-held (*queue-lock*)
    (let ((msgs *incoming-msgs*))
      (setf *incoming-msgs* nil)
      msgs)))

(defun get-line-style (text)
  (cond
    ((uiop:string-prefix-p "*" text) '(:bold :yellow))
    ((uiop:string-prefix-p "⬆" text) '(:cyan))
    ((uiop:string-prefix-p "🤔" text) '(:italic))
    ((uiop:string-prefix-p "ERROR" text) '(:bold :red))
    (t nil)))

Rendering

(defun render-chat (win)
  (clear win)
  (let* ((h (height win))
         (view-height (max 0 (- h 2)))
         (history-len (length *chat-history*))
         (start-idx *scroll-index*)
         (end-idx (min history-len (+ start-idx view-height)))
         (slice (reverse (subseq *chat-history* start-idx end-idx))))
    (loop for msg in slice
          for i from 1
          do (add-string win (format nil "│ ~a" msg) :y i :x 1 :attributes (get-line-style msg)))
    (refresh win)))

Input Handling

(defun handle-backspace ()
  (when (> (fill-pointer *input-buffer*) 0)
    (decf (fill-pointer *input-buffer*))))

(defun handle-return (stream)
  (let ((cmd (coerce *input-buffer* 'string)))
    (setf (fill-pointer *input-buffer*) 0)
    (when (> (length cmd) 0)
      (enqueue-msg (format nil "⬆ ~a" cmd))
      (handler-case
          (when (and stream (open-stream-p stream))
            (format stream "~a" (opencortex:frame-message (list :TYPE :EVENT 
                                                               :META (list :SOURCE :tui)
                                                               :PAYLOAD (list :SENSOR :user-input :TEXT cmd))))
            (finish-output stream))
        (error (c)
          (declare (ignore c))
          (enqueue-msg "ERROR: Connection to daemon lost.")
          (setf *is-running* nil))))
    (when (string= cmd "/exit") (setf *is-running* nil))
    (when (string= cmd "/clear") (setf *chat-history* nil))))

Main Entry Point

(defun main ()
  (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 (height scr)) (w (width scr)))
          (let ((chat-win (make-instance 'window :height (- h 5) :width (- w 2) :position '(1 1) :border t))
                (input-win (make-instance 'window :height 1 :width (- w 2) :position (list (- h 2) 1) :border t)))
          (setf (input-blocking input-win) nil)
          (loop :while *is-running* :do
            (let ((msgs (dequeue-msgs)))
              (when msgs 
                (dolist (m msgs) (push m *chat-history*))
                (render-chat chat-win)))
            (let* ((ev (get-event input-win))
                   (ch (when (and ev (typep ev 'event)) (event-key ev))))
              (when ch
                (cond
                  ((or (eq ch #\Newline) (eq ch #\Return)) (handle-return *stream*))
                  ((or (eq ch :backspace) (eq ch (code-char 127))) (handle-backspace))
                  ((characterp ch) (vector-push-extend ch *input-buffer*))))
              (clear input-win)
              (add-string input-win (format nil "▶ ~a" (coerce *input-buffer* 'string)) :y 0 :x 1)
              (refresh input-win))
            (sleep 0.02)))))
    (setf *is-running* nil)
    (when *socket* (ignore-errors (usocket:socket-close *socket*)))))