feat: Implement croatoan-based TUI client and structured CLI gateway

This commit is contained in:
2026-04-17 13:24:10 -04:00
parent 4612c46f0d
commit 47a2cf6478
4 changed files with 271 additions and 12 deletions

131
literate/tui-client.org Normal file
View File

@@ -0,0 +1,131 @@
:PROPERTIES:
:ID: tui-client-spec
:CREATED: [2026-04-17 Fri 11:00]
:END:
#+TITLE: OpenCortex TUI Client (Standalone)
#+STARTUP: content
#+FILETAGS: :tui:ux:client:
* Overview
The OpenCortex TUI Client is a standalone Common Lisp executable providing a rich, interactive terminal experience via the ~croatoan~ library.
* Phase D: Build (Implementation)
** Package Definition
#+begin_src lisp :tangle ../src/tui-client.lisp
(in-package :cl-user)
(defpackage :opencortex.tui
(:use :cl :croatoan)
(:export :main))
(in-package :opencortex.tui)
#+end_src
** State & Global Queues
#+begin_src lisp :tangle ../src/tui-client.lisp
(defvar *daemon-host* "127.0.0.1")
(defvar *daemon-port* 9105)
(defvar *socket* nil)
(defvar *stream* nil)
(defvar *chat-history* (list))
(defvar *status-text* "Connecting...")
(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)
#+end_src
** Networking Thread
#+begin_src lisp :tangle ../src/tui-client.lisp
(defun enqueue-msg (msg)
(bt:with-lock-held (*queue-lock*)
(push msg *incoming-msgs*)))
(defun dequeue-msgs ()
(bt:with-lock-held (*queue-lock*)
(let ((msgs (nreverse *incoming-msgs*)))
(setf *incoming-msgs* nil)
msgs)))
(defun listen-thread ()
(loop while *is-running* do
(handler-case
(when (and *stream* (open-stream-p *stream*))
(let ((line (read-line *stream* nil :eof)))
(if (eq line :eof)
(setf *is-running* nil)
(let ((json (ignore-errors (cl-json:decode-json-from-string line))))
(if json
(cond
((string= (cdr (assoc :type json)) "status")
(setf *status-text* (format nil "[Scribe: ~a] [Gardener: ~a]"
(cdr (assoc :scribe json))
(cdr (assoc :gardener json)))))
((string= (cdr (assoc :type json)) "chat")
(enqueue-msg (cdr (assoc :text json))))
(t (enqueue-msg line)))
(enqueue-msg line))))))
(error () (setf *is-running* nil)))
(sleep 0.05)))
#+end_src
** Main TUI Loop
#+begin_src lisp :tangle ../src/tui-client.lisp
(defun main ()
(handler-case
(setf *socket* (usocket:socket-connect *daemon-host* *daemon-port*))
(error (e) (format t "Error connecting: ~a~%" e) (return-from main)))
(setf *stream* (usocket:socket-stream *socket*))
(bt:make-thread #'listen-thread :name "tui-listener")
(unwind-protect
(with-screen (scr :input-echoing nil :input-blocking nil :enable-colors t :cursor-visible t)
(let* ((h (height scr))
(w (width scr))
(chat-win (make-window (list (- h 2) w 0 0)))
(status-win (make-window (list 1 w (- h 2) 0)))
(input-win (make-window (list 1 w (- h 1) 0))))
(loop while *is-running* do
;; Handle incoming messages
(let ((new-msgs (dequeue-msgs)))
(when new-msgs
(dolist (msg new-msgs)
(push msg *chat-history*)
(setf *chat-history* (subseq *chat-history* 0 (min (length *chat-history*) 500))))
(clear chat-win)
(let ((line-num 0))
(dolist (m (reverse (subseq *chat-history* 0 (min (length *chat-history*) (- h 3)))))
(add-string chat-win m :y line-num :x 0)
(incf line-num)))
(refresh chat-win)))
;; Render Status Bar
(clear status-win)
(add-string status-win *status-text* :attributes '(:reverse))
(refresh status-win)
;; Handle Keyboard Input
(let ((ch (get-char scr)))
(when ch
(cond
((eq ch #\Newline)
(let ((cmd (coerce *input-buffer* 'string)))
(setf (fill-pointer *input-buffer*) 0)
(format *stream* "~a~%" cmd)
(finish-output *stream*)
(when (string= cmd "/exit") (setf *is-running* nil))))
((eq ch :backspace)
(when (> (length *input-buffer*) 0)
(decf (fill-pointer *input-buffer*))))
((characterp ch)
(vector-push-extend ch *input-buffer*)))
(clear input-win)
(add-string input-win (concatenate 'string "> " (coerce *input-buffer* 'string)))
(refresh input-win)))
(sleep 0.02))))
(setf *is-running* nil)
(when *socket* (usocket:socket-close *socket*))))
#+end_src

View File

@@ -4,7 +4,7 @@
:version "0.1.0" :version "0.1.0"
:license "AGPLv3" :license "AGPLv3"
:description "The Probabilistic-Deterministic Lisp Machine Harness" :description "The Probabilistic-Deterministic Lisp Machine Harness"
:depends-on (:usocket :bordeaux-threads :dexador :uiop :cl-dotenv :cl-ppcre :hunchentoot :ironclad :str :cl-json :uuid) :depends-on (:usocket :bordeaux-threads :dexador :uiop :cl-dotenv :cl-ppcre :hunchentoot :ironclad :str :cl-json :uuid :croatoan)
:serial t :serial t
:components ((:file "src/package") :components ((:file "src/package")
(:file "src/skills") (:file "src/skills")
@@ -37,3 +37,10 @@
(uiop:symbol-call :fiveam :run! (uiop:find-symbol* :boot-suite :opencortex-boot-tests)) (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :boot-suite :opencortex-boot-tests))
(uiop:symbol-call :fiveam :run! (uiop:find-symbol* :memory-suite :opencortex-memory-tests)) (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :memory-suite :opencortex-memory-tests))
(uiop:symbol-call :fiveam :run! (uiop:find-symbol* :immune-suite :opencortex-immune-system-tests)))) (uiop:symbol-call :fiveam :run! (uiop:find-symbol* :immune-suite :opencortex-immune-system-tests))))
(defsystem :opencortex/tui
:depends-on (:opencortex :croatoan :usocket :bordeaux-threads :cl-json)
:components ((:file "src/tui-client"))
:build-operation "program-op"
:build-pathname "opencortex-tui"
:entry-point "opencortex.tui:main")

View File

@@ -61,7 +61,8 @@ The CLI actuator writes the agent's response back to the client's network stream
(handler-case (handler-case
(if (and stream (open-stream-p stream)) (if (and stream (open-stream-p stream))
(progn (progn
(format stream "Agent: ~a~%" text) (format stream "{\"type\": \"chat\", \"text\": \"~a\"}" text)
(terpri stream)
(finish-output stream)) (finish-output stream))
(harness-log "CLI ERROR: No active or open reply stream for signal.")) (harness-log "CLI ERROR: No active or open reply stream for signal."))
(error (c) (harness-log "CLI ACTUATOR ERROR: ~a" c))))) (error (c) (harness-log "CLI ACTUATOR ERROR: ~a" c)))))
@@ -71,24 +72,38 @@ The CLI actuator writes the agent's response back to the client's network stream
Handles an individual TCP connection. It reads lines until the connection is closed. Handles an individual TCP connection. It reads lines until the connection is closed.
#+begin_src lisp #+begin_src lisp
(defun handle-cli-slash-command (cmd stream)
"Handles TUI slash commands by returning structured JSON."
(cond
((string= cmd "/status")
(format stream "{\"type\": \"status\", \"scribe\": \"idle\", \"gardener\": \"sleeping\"}")
(terpri stream)
(finish-output stream))
((string= cmd "/exit")
(format stream "{\"type\": \"info\", \"text\": \"Goodbye!\"}")
(terpri stream)
(finish-output stream))
(t (format stream "{\"type\": \"error\", \"text\": \"Unknown command: ~a\"}" cmd)
(terpri stream)
(finish-output stream))))
(defun handle-cli-client (stream) (defun handle-cli-client (stream)
"Reads lines from a CLI client and injects them as stimuli." "Reads lines from a CLI client and injects them as stimuli."
(harness-log "CLI: Client connected.") (harness-log "CLI: Client connected.")
(format stream "--------------------------------------------------~%")
(format stream " Connected to OpenCortex~%")
(format stream "--------------------------------------------------~%")
(finish-output stream)
(handler-case (handler-case
(loop for line = (read-line stream nil nil) (loop for line = (read-line stream nil nil)
while line do while line do
(let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) line))) (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) line)))
(when (> (length trimmed) 0) (when (> (length trimmed) 0)
(harness-log "CLI: Received input -> ~a" trimmed) (if (and (> (length trimmed) 0) (char= (char trimmed 0) #\/))
(inject-stimulus (list :type :EVENT (handle-cli-slash-command trimmed stream)
:payload (list :sensor :chat-message (progn
:channel :cli (harness-log "CLI: Received input -> ~a" trimmed)
:text trimmed)) (inject-stimulus (list :type :EVENT
:stream stream)))) :payload (list :sensor :chat-message
:channel :cli
:text trimmed))
:stream stream))))))
(error (c) (harness-log "CLI CLIENT DISCONNECT: ~a" c))) (error (c) (harness-log "CLI CLIENT DISCONNECT: ~a" c)))
(harness-log "CLI: Client disconnected.")) (harness-log "CLI: Client disconnected."))
#+end_src #+end_src

106
src/tui-client.lisp Normal file
View File

@@ -0,0 +1,106 @@
(in-package :cl-user)
(defpackage :opencortex.tui
(:use :cl :croatoan)
(:export :main))
(in-package :opencortex.tui)
(defvar *daemon-host* "127.0.0.1")
(defvar *daemon-port* 9105)
(defvar *socket* nil)
(defvar *stream* nil)
(defvar *chat-history* (list))
(defvar *status-text* "Connecting...")
(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)
(defun enqueue-msg (msg)
(bt:with-lock-held (*queue-lock*)
(push msg *incoming-msgs*)))
(defun dequeue-msgs ()
(bt:with-lock-held (*queue-lock*)
(let ((msgs (nreverse *incoming-msgs*)))
(setf *incoming-msgs* nil)
msgs)))
(defun listen-thread ()
(loop while *is-running* do
(handler-case
(when (and *stream* (open-stream-p *stream*))
(let ((line (read-line *stream* nil :eof)))
(if (eq line :eof)
(setf *is-running* nil)
(let ((json (ignore-errors (cl-json:decode-json-from-string line))))
(if json
(cond
((string= (cdr (assoc :type json)) "status")
(setf *status-text* (format nil "[Scribe: ~a] [Gardener: ~a]"
(cdr (assoc :scribe json))
(cdr (assoc :gardener json)))))
((string= (cdr (assoc :type json)) "chat")
(enqueue-msg (cdr (assoc :text json))))
(t (enqueue-msg line)))
(enqueue-msg line))))))
(error () (setf *is-running* nil)))
(sleep 0.05)))
(defun main ()
(handler-case
(setf *socket* (usocket:socket-connect *daemon-host* *daemon-port*))
(error (e) (format t "Error connecting: ~a~%" e) (return-from main)))
(setf *stream* (usocket:socket-stream *socket*))
(bt:make-thread #'listen-thread :name "tui-listener")
(unwind-protect
(with-screen (scr :input-echoing nil :input-blocking nil :enable-colors t :cursor-visible t)
(let* ((h (height scr))
(w (width scr))
(chat-win (make-window (list (- h 2) w 0 0)))
(status-win (make-window (list 1 w (- h 2) 0)))
(input-win (make-window (list 1 w (- h 1) 0))))
(loop while *is-running* do
;; Handle incoming messages
(let ((new-msgs (dequeue-msgs)))
(when new-msgs
(dolist (msg new-msgs)
(push msg *chat-history*)
(setf *chat-history* (subseq *chat-history* 0 (min (length *chat-history*) 500))))
(clear chat-win)
(let ((line-num 0))
(dolist (m (reverse (subseq *chat-history* 0 (min (length *chat-history*) (- h 3)))))
(add-string chat-win m :y line-num :x 0)
(incf line-num)))
(refresh chat-win)))
;; Render Status Bar
(clear status-win)
(add-string status-win *status-text* :attributes '(:reverse))
(refresh status-win)
;; Handle Keyboard Input
(let ((ch (get-char scr)))
(when ch
(cond
((eq ch #\Newline)
(let ((cmd (coerce *input-buffer* 'string)))
(setf (fill-pointer *input-buffer*) 0)
(format *stream* "~a~%" cmd)
(finish-output *stream*)
(when (string= cmd "/exit") (setf *is-running* nil))))
((eq ch :backspace)
(when (> (length *input-buffer*) 0)
(decf (fill-pointer *input-buffer*))))
((characterp ch)
(vector-push-extend ch *input-buffer*)))
(clear input-win)
(add-string input-win (concatenate 'string "> " (coerce *input-buffer* 'string)))
(refresh input-win)))
(sleep 0.02))))
(setf *is-running* nil)
(when *socket* (usocket:socket-close *socket*))))