233 lines
9.5 KiB
Org Mode
233 lines
9.5 KiB
Org Mode
#+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-command-parser
|
|
"Verify that slash-commands are correctly identified."
|
|
;; Stub for now
|
|
(is (null nil)))
|
|
#+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))
|
|
(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)))
|
|
(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
|
|
|