#+PROPERTY: header-args:lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) :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 application built on **Croatoan**. It provides a real-time, multi-window interface for interacting with the OpenCortex daemon. * Phase A: Demand (Thinking) ** The Professional Interface A simple MVP console is insufficient for a Lisp Machine. To reach v0.2.0, the TUI must facilitate high-density information exchange. ** Design Invariants: 1. **Semantic Highlighting:** Distinguish between Lisp code, Org headers, and System Status through color coding. 2. **Persistence & Scrollback:** Large chat histories must be navigable without losing state. 3. **Command Palette:** A consistent way to invoke meta-functions (e.g., `/doctor`, `/clear`) without leaving the UI. * Phase B: Protocol (Success Criteria) ** Test Suite Context #+begin_src lisp :tangle (expand-file-name "tui-tests.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/tests")) (defpackage :opencortex-tui-tests (:use :cl :fiveam :opencortex) (:export #:tui-suite)) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-tests.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/tests")) (in-package :opencortex-tui-tests) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-tests.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/tests")) (def-suite tui-suite :description "Verification of the TUI parsing and styling logic") #+end_src #+begin_src lisp :tangle (expand-file-name "tui-tests.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/tests")) (in-suite tui-suite) #+end_src ** Command Parsing Tests #+begin_src lisp :tangle (expand-file-name "tui-tests.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/tests")) (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 (is (member "ERROR: Connection to daemon lost." opencortex.tui::*incoming-msgs* :test #'string=)))) #+end_src * Phase C: Implementation (Build) ** Package Context #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (in-package :cl-user) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defpackage :opencortex.tui (:use :cl :croatoan) (:export :main)) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (in-package :opencortex.tui) #+end_src ** Global State #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *daemon-host* "127.0.0.1") #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *daemon-port* 9105) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *socket* nil) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *stream* nil) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *chat-history* (list) "Full chronological log of messages.") #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *scroll-index* 0 "Offset for history rendering.") #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *status-text* "Connecting...") #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *input-buffer* (make-array 0 :element-type 'char :fill-pointer 0 :adjustable t)) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *command-history* (make-array 0 :element-type 't :fill-pointer 0 :adjustable t)) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *history-index* -1) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *is-running* t) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *queue-lock* (bt:make-lock)) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defvar *incoming-msgs* nil) #+end_src ** Utilities #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defun enqueue-msg (msg) "Thread-safe addition to incoming message queue." (bt:with-lock-held (*queue-lock*) (push msg *incoming-msgs*))) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defun dequeue-msgs () "Thread-safe retrieval of incoming messages." (bt:with-lock-held (*queue-lock*) (let ((msgs (nreverse *incoming-msgs*))) (setf *incoming-msgs* nil) msgs))) #+end_src ** Styling Engine #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defun get-line-style (text) "Determines croatoan attributes based on content patterns." (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))) #+end_src ** Rendering Orchestrator #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defun render-chat (win) "Renders the chat history with scrolling and styling." (clear win) (let* ((h (height win)) (view-height (- 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 (let ((style (get-line-style msg))) (add-string win (format nil "│ ~a" msg) :y i :x 1 :attributes style))) (refresh win))) #+end_src ** Input Handling #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defun handle-backspace () "Deletes last character from input buffer." (when (> (fill-pointer *input-buffer*) 0) (decf (fill-pointer *input-buffer*)))) #+end_src #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defun handle-return (stream) "Process input buffer as message or command." (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) (push "ERROR: Connection to daemon lost." *chat-history*) (setf *is-running* nil)))) (when (string= cmd "/exit") (setf *is-running* nil)) (when (string= cmd "/clear") (setf *chat-history* nil)))) #+end_src ** Main Entry Point #+begin_src lisp :tangle (expand-file-name "tui-client.lisp" (concat (or (getenv "INSTALL_DIR") ".") "/harness")) (defun main () "Initializes ncurses and starts the TUI event loop." (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))) (unless (and h w) (error "Screen dimensions are NIL: h=~a, w=~a" h w)) (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*))))) #+end_src