From 47a2cf64785b2f7fc955555efc2c330fe4cf089a Mon Sep 17 00:00:00 2001 From: Amr Gharbeia Date: Fri, 17 Apr 2026 13:24:10 -0400 Subject: [PATCH] feat: Implement croatoan-based TUI client and structured CLI gateway --- literate/tui-client.org | 131 +++++++++++++++++++++++++++++++ opencortex.asd | 9 ++- skills/org-skill-cli-gateway.org | 37 ++++++--- src/tui-client.lisp | 106 +++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 literate/tui-client.org create mode 100644 src/tui-client.lisp diff --git a/literate/tui-client.org b/literate/tui-client.org new file mode 100644 index 0000000..97e4a3e --- /dev/null +++ b/literate/tui-client.org @@ -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 diff --git a/opencortex.asd b/opencortex.asd index 3327586..d7caf23 100644 --- a/opencortex.asd +++ b/opencortex.asd @@ -4,7 +4,7 @@ :version "0.1.0" :license "AGPLv3" :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 :components ((:file "src/package") (: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* :memory-suite :opencortex-memory-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") diff --git a/skills/org-skill-cli-gateway.org b/skills/org-skill-cli-gateway.org index 905db74..3ade57a 100644 --- a/skills/org-skill-cli-gateway.org +++ b/skills/org-skill-cli-gateway.org @@ -61,7 +61,8 @@ The CLI actuator writes the agent's response back to the client's network stream (handler-case (if (and stream (open-stream-p stream)) (progn - (format stream "Agent: ~a~%" text) + (format stream "{\"type\": \"chat\", \"text\": \"~a\"}" text) + (terpri stream) (finish-output stream)) (harness-log "CLI ERROR: No active or open reply stream for signal.")) (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. #+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) "Reads lines from a CLI client and injects them as stimuli." (harness-log "CLI: Client connected.") - (format stream "--------------------------------------------------~%") - (format stream " Connected to OpenCortex~%") - (format stream "--------------------------------------------------~%") - (finish-output stream) (handler-case (loop for line = (read-line stream nil nil) while line do (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) line))) (when (> (length trimmed) 0) - (harness-log "CLI: Received input -> ~a" trimmed) - (inject-stimulus (list :type :EVENT - :payload (list :sensor :chat-message - :channel :cli - :text trimmed)) - :stream stream)))) + (if (and (> (length trimmed) 0) (char= (char trimmed 0) #\/)) + (handle-cli-slash-command trimmed stream) + (progn + (harness-log "CLI: Received input -> ~a" trimmed) + (inject-stimulus (list :type :EVENT + :payload (list :sensor :chat-message + :channel :cli + :text trimmed)) + :stream stream)))))) (error (c) (harness-log "CLI CLIENT DISCONNECT: ~a" c))) (harness-log "CLI: Client disconnected.")) #+end_src diff --git a/src/tui-client.lisp b/src/tui-client.lisp new file mode 100644 index 0000000..67b829d --- /dev/null +++ b/src/tui-client.lisp @@ -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*)))) \ No newline at end of file