Some checks failed
Deploy (Gitea) / deploy (push) Failing after 11s
- Secret Exposure Gate + Privacy Filter (Bouncer) - Shell actuator safety harness (timeout, blocked patterns) - REPL-first enforcement (lisp validation gate, system-prompt-augment) - Engineering Standards lifecycle (two-track Org-first + REPL-first) - Literate Programming discipline (one function per block, reflect-back) - AGENTS.md: thin routing layer, skills are authoritative - SKILLS_DIR removed, ~/notes fallback eliminated - opencortex.sh: multi-distro (Debian+Fedora), configure, install service, backup, restore, help - infrastructure/opencortex.service (systemd user unit) - Docker: updated to debian:trixie, fixed build context - GitHub CI: lint + test workflows fixed, trigger on tags only - Gitea CI: deploy workflow paths fixed - README: one-line curl install, badges - USER_MANUAL: Deployment section (bare metal, Docker, backup) - .gitignore: skills/*.lisp and tests/*.lisp as generated artifacts - Prose/block refactor across all 35 org files - Test suite Tier 1: 43/45 pass (env-dependent failures isolated)
230 lines
7.8 KiB
Org Mode
230 lines
7.8 KiB
Org Mode
#+TITLE: OpenCortex TUI Client (Standalone)
|
|
#+STARTUP: content
|
|
#+FILETAGS: :tui:ux:client:
|
|
#+PROPERTY: header-args:lisp :tangle tui-client.lisp
|
|
|
|
* Overview
|
|
The TUI Client is a standalone ncurses application (built on Croatoan) that connects to the daemon via TCP. It provides a split-pane interface: a scrollable chat history window and a fixed input line at the bottom. Connected to the daemon at ~localhost:9105~, it sends user input as framed protocol messages and displays responses as they arrive from the daemon's background reader thread.
|
|
|
|
* Implementation
|
|
|
|
** Package Context
|
|
#+begin_src lisp
|
|
(in-package :cl-user)
|
|
(defpackage :opencortex.tui
|
|
(:use :cl :croatoan :usocket :bordeaux-threads)
|
|
(:export :main))
|
|
(in-package :opencortex.tui)
|
|
#+end_src
|
|
|
|
** Connection state
|
|
#+begin_src lisp
|
|
(defvar *daemon-host* "localhost")
|
|
#+end_src
|
|
|
|
#+begin_src lisp
|
|
(defvar *daemon-port* 9105)
|
|
#+end_src
|
|
|
|
#+begin_src lisp
|
|
(defvar *socket* nil)
|
|
#+end_src
|
|
|
|
#+begin_src lisp
|
|
(defvar *stream* nil)
|
|
#+end_src
|
|
|
|
** UI state
|
|
#+begin_src lisp
|
|
(defvar *chat-history* nil)
|
|
#+end_src
|
|
|
|
#+begin_src lisp
|
|
(defvar *input-list* nil)
|
|
#+end_src
|
|
|
|
#+begin_src lisp
|
|
(defvar *is-running* t)
|
|
#+end_src
|
|
|
|
** Thread-safe message queue
|
|
#+begin_src lisp
|
|
(defvar *queue-lock* (bt:make-lock "incoming-queue-lock"))
|
|
#+end_src
|
|
|
|
#+begin_src lisp
|
|
(defvar *incoming-msgs* nil)
|
|
#+end_src
|
|
|
|
** Utilities
|
|
#+begin_src lisp
|
|
(defun log-debug (msg &rest args)
|
|
(ignore-errors
|
|
(with-open-file (s "/tmp/opencortex-tui-debug.log" :direction :output :if-exists :append :if-does-not-exist :create)
|
|
(format s "[~a] " (get-universal-time))
|
|
(apply #'format s msg args)
|
|
(terpri s)
|
|
(finish-output s))))
|
|
|
|
(defun enqueue-msg (msg)
|
|
(bt:with-lock-held (*queue-lock*)
|
|
(setf *incoming-msgs* (append *incoming-msgs* (list msg)))))
|
|
|
|
(defun dequeue-msgs ()
|
|
(bt:with-lock-held (*queue-lock*)
|
|
(let ((msgs *incoming-msgs*))
|
|
(setf *incoming-msgs* nil)
|
|
msgs)))
|
|
#+end_src
|
|
|
|
** Rendering
|
|
#+begin_src lisp
|
|
(defun render-chat (win h)
|
|
(when (and win (integerp h))
|
|
(clear win)
|
|
(box win 0 0)
|
|
(let* ((view-height (- h 2))
|
|
(history (copy-list *chat-history*))
|
|
(len (length history))
|
|
(num-to-draw (min len view-height))
|
|
(slice (subseq history 0 num-to-draw)))
|
|
(loop for i from 0 below num-to-draw
|
|
for msg in (reverse slice)
|
|
do (when msg
|
|
(add-string win (format nil "│ ~a" msg) :y (1+ i) :x 2))))
|
|
(refresh win)))
|
|
#+end_src
|
|
|
|
** Input Handling
|
|
#+begin_src lisp
|
|
(defun handle-backspace ()
|
|
(pop *input-list*))
|
|
|
|
(defun handle-return (stream)
|
|
(let ((cmd (coerce (reverse *input-list*) 'string)))
|
|
(setf *input-list* nil)
|
|
(log-debug "SUBMITTING: '~a'" cmd)
|
|
(when (> (length cmd) 0)
|
|
(push (format nil "⬆ ~a" cmd) *chat-history*)
|
|
(handler-case
|
|
(progn
|
|
(if (and stream (open-stream-p stream))
|
|
(let* ((msg (list :TYPE :EVENT
|
|
:META (list :SOURCE :tui)
|
|
:PAYLOAD (list :SENSOR :user-input :TEXT cmd)))
|
|
(payload (format nil "~s" msg))
|
|
(len (length payload)))
|
|
(format stream "~6,'0x~a" len payload)
|
|
(finish-output stream)
|
|
(log-debug "SENT WIRE: ~a" payload))
|
|
(push "ERROR: Not connected." *chat-history*)))
|
|
(error (c)
|
|
(log-debug "SEND ERROR: ~a" c)
|
|
(push (format nil "ERROR: ~a" c) *chat-history*)
|
|
(setf *is-running* nil))))
|
|
(when (string= cmd "/exit") (setf *is-running* nil))
|
|
(when (string= cmd "/clear") (setf *chat-history* nil))))
|
|
#+end_src
|
|
|
|
** Background Reader
|
|
#+begin_src lisp
|
|
(defun start-background-reader (stream)
|
|
(bt:make-thread
|
|
(lambda ()
|
|
(loop while *is-running* do
|
|
(handler-case
|
|
(let* ((len-buf (make-string 6))
|
|
(count (read-sequence len-buf stream)))
|
|
(if (= count 6)
|
|
(let* ((msg-len (parse-integer len-buf :radix 16))
|
|
(msg-buf (make-string msg-len)))
|
|
(read-sequence msg-buf stream)
|
|
(log-debug "DAEMON MSG: ~a" msg-buf)
|
|
(let ((msg (read-from-string msg-buf)))
|
|
(let ((payload (getf msg :payload)))
|
|
(cond
|
|
((eq (getf payload :action) :handshake)
|
|
(enqueue-msg "* Connected *"))
|
|
(t
|
|
(let ((text (or (getf payload :text) (format nil "~a" payload))))
|
|
(enqueue-msg (format nil "⬇ ~a" text))))))))
|
|
(sleep 0.05)))
|
|
(error (c)
|
|
(when *is-running*
|
|
(log-debug "READER ERROR: ~a" c)
|
|
(enqueue-msg "ERROR: Connection lost.")
|
|
(setf *is-running* nil))))))
|
|
:name "opencortex-tui-reader"))
|
|
#+end_src
|
|
|
|
** Main Entry Point
|
|
#+begin_src lisp
|
|
(defun main ()
|
|
(log-debug "=== START ===")
|
|
(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 (or (height scr) 24))
|
|
(w (or (width scr) 80))
|
|
(chat-h (- h 4))
|
|
(chat-win (make-instance 'window :height chat-h :width (- w 2) :y 1 :x 1))
|
|
(input-win (make-instance 'window :height 1 :width (- w 2) :y (- h 2) :x 1)))
|
|
(setf (input-blocking input-win) nil)
|
|
(start-background-reader *stream*)
|
|
(loop :while *is-running* :do
|
|
(let ((msgs (dequeue-msgs)))
|
|
(when msgs
|
|
(dolist (m msgs) (push m *chat-history*))
|
|
(render-chat chat-win chat-h)))
|
|
(let ((ch (get-char input-win)))
|
|
(when (and ch (not (equal ch -1)))
|
|
(log-debug "KEY: ~s" ch)
|
|
(cond
|
|
((or (eql ch 10) (eql ch 13) (eq ch :enter) (eql ch #\Newline) (eql ch #\Return))
|
|
(handle-return *stream*)
|
|
(render-chat chat-win chat-h))
|
|
((or (eql ch 127) (eql ch 8) (eq ch :backspace) (eql ch #\Backspace))
|
|
(handle-backspace))
|
|
((characterp ch)
|
|
(push ch *input-list*))
|
|
((integerp ch)
|
|
(let ((converted (code-char ch)))
|
|
(when (graphic-char-p converted)
|
|
(push converted *input-list*))))))
|
|
(clear input-win)
|
|
(add-string input-win (format nil "▶ ~a" (coerce (reverse *input-list*) 'string)) :y 0 :x 1)
|
|
(refresh input-win))
|
|
(sleep 0.01))))
|
|
(setf *is-running* nil)
|
|
(when *socket* (ignore-errors (usocket:socket-close *socket*)))))
|
|
#+end_src
|
|
|
|
** REPL test script (tmux)
|
|
Use this script to test the TUI non-interactively in a tmux session. It launches the TUI in a headless tmux window, sends text, and captures the output.
|
|
|
|
#+begin_src bash :tangle no
|
|
#!/bin/bash
|
|
SESSION="oct-tui-test"
|
|
tmux new-session -d -s "$SESSION" \
|
|
-e OC_CONFIG_DIR="$HOME/.config/opencortex" \
|
|
-e OC_DATA_DIR="$HOME/.local/share/opencortex" \
|
|
-e TERM="screen-256color" \
|
|
"sbcl --non-interactive \
|
|
--eval '(load (merge-pathnames \"quicklisp/setup.lisp\" (user-homedir-pathname)))' \
|
|
--eval '(push (truename \"$HOME/.local/share/opencortex/\") asdf:*central-registry*)' \
|
|
--eval '(ql:quickload :opencortex/tui)' \
|
|
--eval '(opencortex.tui:main)'"
|
|
sleep 5
|
|
tmux capture-pane -t "$SESSION" -p -S -20
|
|
tmux send-keys -t "$SESSION" 'hello' Enter
|
|
sleep 8
|
|
tmux capture-pane -t "$SESSION" -p -S -20
|
|
tmux send-keys -t "$SESSION" '/exit' Enter
|
|
sleep 1
|
|
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
|
#+end_src
|