TUI rewrite: M/V/U + /eval REPL + Swank
- Model-View-Update architecture: *state* plist, pure views, event handlers - /eval command: split view: inspect state, test functions, mutate live - Swank REPL on port 4006 (configurable via TUI_SWANK_PORT env var) - Character-based daemon I/O (consistent with daemon protocol) - Per-function refresh pattern (matches Croatoan working model) - Fixed Enter/Backspace key detection for Croatoan integer returns - Swank loaded dynamically via find-symbol (no reader dependency)
This commit is contained in:
@@ -3,373 +3,238 @@
|
||||
(:export :tui-main))
|
||||
(in-package :passepartout.gateway-tui)
|
||||
|
||||
(declaim (optimize (debug 3) (safety 3) (speed 0)))
|
||||
(defvar *state* nil)
|
||||
(defvar *event-queue* nil)
|
||||
(defvar *event-lock* (bt:make-lock "tui-event-lock"))
|
||||
|
||||
(defvar *stream* nil "TCP stream to daemon")
|
||||
(defvar *input-buffer* nil "Current input line as reversed char list")
|
||||
(defvar *input-history* nil "Sent messages (newest first)")
|
||||
(defvar *input-history-pos* 0 "Position in input history for nav")
|
||||
(defvar *chat-history* nil "List of (direction . text) entries")
|
||||
(defvar *chat-scroll-pos* 0 "How many lines from bottom we scrolled up")
|
||||
(defvar *is-running* t "Main loop flag")
|
||||
(defvar *tui-mode* :chat "Current mode: :chat or :config")
|
||||
(defvar *config-selection* 0 "Selected menu item in config panel")
|
||||
(defvar *config-panel-height* 10 "Height of config panel when open")
|
||||
(defun st (key) (getf *state* key))
|
||||
(defun (setf st) (val key) (setf (getf *state* key) val))
|
||||
|
||||
(defun send-message (stream msg)
|
||||
"Send a framed s-expression over TCP."
|
||||
(let* ((sexp (format nil "~s" msg))
|
||||
(len (length sexp))
|
||||
(header (format nil "~6,'0x" len)))
|
||||
(write-sequence (babel:string-to-octets (concatenate 'string header sexp)) stream)
|
||||
(force-output stream)))
|
||||
(defun init-state ()
|
||||
(setf *state*
|
||||
(list :running t :mode :chat :connected nil :stream nil
|
||||
:input-buffer nil :input-history nil :input-hpos 0
|
||||
:messages nil :scroll-offset 0 :dirty (list nil nil nil))))
|
||||
|
||||
|
||||
(defun read-message (stream)
|
||||
"Read a framed s-expression from TCP."
|
||||
(handler-case
|
||||
(let* ((header (make-array 6 :element-type '(unsigned-byte 8)))
|
||||
(read (read-sequence header stream)))
|
||||
(when (< read 6) (return-from read-message nil))
|
||||
(let* ((hex (babel:octets-to-string header))
|
||||
(len (parse-integer hex :radix 16 :junk-allowed t))
|
||||
(buf (make-array len :element-type '(unsigned-byte 8))))
|
||||
(read-sequence buf stream)
|
||||
(read-from-string (babel:octets-to-string buf))))
|
||||
(error () nil)))
|
||||
|
||||
(defvar *message-queue* nil
|
||||
"List of incoming daemon messages from reader thread.")
|
||||
(defvar *message-queue-lock* (bt:make-lock "msg-queue-lock")
|
||||
"Lock for thread-safe queue access.")
|
||||
|
||||
(defun message-queue-push (msg)
|
||||
(bt:acquire-lock *message-queue-lock*)
|
||||
(push msg *message-queue*)
|
||||
(bt:release-lock *message-queue-lock*))
|
||||
|
||||
(defun message-queue-drain ()
|
||||
"Drain all messages from queue. Returns list in FIFO order."
|
||||
(bt:acquire-lock *message-queue-lock*)
|
||||
(let ((msgs (nreverse *message-queue*)))
|
||||
(setf *message-queue* nil)
|
||||
(bt:release-lock *message-queue-lock*)
|
||||
msgs))
|
||||
|
||||
(defun reader-thread (stream)
|
||||
"Background thread: reads daemon messages and enqueues them."
|
||||
(loop while *is-running*
|
||||
do (let ((msg (read-message stream)))
|
||||
(when msg
|
||||
(message-queue-push msg)))))
|
||||
|
||||
(defun input-text ()
|
||||
"Convert input buffer to string."
|
||||
(coerce (reverse *input-buffer*) 'string))
|
||||
|
||||
(defun input-backspace ()
|
||||
(when *input-buffer* (pop *input-buffer*)))
|
||||
|
||||
(defun input-submit (stream)
|
||||
"Send current input buffer content to daemon."
|
||||
(let ((text (string-trim '(#\Space) (input-text))))
|
||||
(when (> (length text) 0)
|
||||
(push text *input-history*)
|
||||
(setf *input-history-pos* 0)
|
||||
(send-message stream (list :type :event :payload (list :sensor :user-input :text text)))
|
||||
(push (cons :sent text) *chat-history*)
|
||||
(setf *input-buffer* nil))))
|
||||
|
||||
(defun input-history-nav (dir)
|
||||
"Navigate input history. Returns new value or nil."
|
||||
(let ((len (length *input-history*)))
|
||||
(when (= len 0) (return-from input-history-nav nil))
|
||||
(case dir
|
||||
(:up (if (< *input-history-pos* (1- len))
|
||||
(incf *input-history-pos*)
|
||||
nil))
|
||||
(:down (if (> *input-history-pos* 0)
|
||||
(decf *input-history-pos*)
|
||||
(progn (setf *input-history-pos* 0) nil)))))
|
||||
(when (>= *input-history-pos* 0)
|
||||
(nth *input-history-pos* *input-history*)))
|
||||
|
||||
(defun timestamp ()
|
||||
"Return HH:MM timestamp string."
|
||||
(defun now ()
|
||||
(multiple-value-bind (h m) (get-decoded-time)
|
||||
(format nil "~2,'0d:~2,'0d" h m)))
|
||||
|
||||
(defun status-render (win)
|
||||
"Draw the status bar."
|
||||
(let ((h (or (height win) 3))
|
||||
(w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win (format nil " Passepartout ~a [~a] msgs:~a scroll:~a"
|
||||
(if *stream* "● Connected" "○ Disconnected")
|
||||
(ecase *tui-mode* (:chat "CHAT") (:config "CONFIG"))
|
||||
(length *chat-history*)
|
||||
(if (> *chat-scroll-pos* 0) (format nil "~a↑" *chat-scroll-pos*) "0")) :y 1 :x 1)
|
||||
(add-string win (format nil " ~a" (timestamp)) :y 2 :x 1)
|
||||
(refresh win)))
|
||||
(defun input-string ()
|
||||
(coerce (reverse (st :input-buffer)) 'string))
|
||||
|
||||
(defun chat-render (win h)
|
||||
"Draw the chat history within WIN of height H."
|
||||
(defun add-msg (role content)
|
||||
(push (list :role role :content content :time (now)) (st :messages))
|
||||
(setf (st :dirty) (list t t nil)))
|
||||
|
||||
(defun view-status (win)
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(let* ((max-lines (- h 2))
|
||||
(total (length *chat-history*))
|
||||
(start (max 0 (- total max-lines *chat-scroll-pos*)))
|
||||
(add-string win
|
||||
(format nil " Passepartout ~a [~a] msgs:~a"
|
||||
(if (st :connected) "● Connected" "○ Disconnected")
|
||||
(string-upcase (string (st :mode)))
|
||||
(length (st :messages)))
|
||||
:y 1 :x 1)
|
||||
(add-string win (format nil " ~a" (now)) :y 2 :x 1)
|
||||
(refresh win))
|
||||
|
||||
(defun view-chat (win h)
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(let* ((w (or (width win) 78))
|
||||
(msgs (reverse (st :messages)))
|
||||
(max-lines (- h 2))
|
||||
(total (length msgs))
|
||||
(start (max 0 (- total max-lines (st :scroll-offset))))
|
||||
(y 1))
|
||||
(loop for i from start below total
|
||||
do (let ((entry (nth i *chat-history*)))
|
||||
(when (and (< y (1- h)) (car entry))
|
||||
(let* ((dir (car entry))
|
||||
(text (cdr entry))
|
||||
(prefix (if (eq dir :sent) "⬆" "⬇"))
|
||||
(label (format nil "~a [~a] ~a" prefix (timestamp) text)))
|
||||
(add-string win label :y y :x 1)
|
||||
(incf y))))))
|
||||
while (< y (1- h))
|
||||
do (let ((msg (nth i msgs)))
|
||||
(let* ((role (getf msg :role))
|
||||
(content (or (getf msg :content) ""))
|
||||
(time (or (getf msg :time) ""))
|
||||
(marker (case role (:user ">") (t " ")))
|
||||
(line (format nil "~a [~a] ~a" marker time content)))
|
||||
(add-string win line :y y :x 1 :n (1- w))
|
||||
(incf y)))))
|
||||
(refresh win))
|
||||
|
||||
(defun input-render (win)
|
||||
"Draw the input line."
|
||||
(defun view-input (win)
|
||||
(clear win)
|
||||
(add-string win (input-text) :y 0 :x 0)
|
||||
(let* ((text (input-string))
|
||||
(w (or (width win) 78))
|
||||
(clip (min (length text) (1- w))))
|
||||
(add-string win text :y 0 :x 0 :n clip)
|
||||
(setf (cursor-position win) (list 0 clip)))
|
||||
(refresh win))
|
||||
|
||||
(defun config-provider-line (provider)
|
||||
"Return formatted provider line: ' ✓ openrouter' or ' ✗ openrouter'."
|
||||
(format nil " ~:[✗~;✓~] ~(~a~)" (provider-available-p provider) provider))
|
||||
(defun queue-event (ev)
|
||||
(bt:with-lock-held (*event-lock*) (push ev *event-queue*)))
|
||||
|
||||
(defun config-render (win)
|
||||
"Draw the config mini-buffer panel with menu and provider overview."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(add-string win (format nil " Set provider: ~a"
|
||||
(mapcar #'config-provider-line *provider-cascade*)) :y 2 :x 1)
|
||||
;; Show unconfigured but available providers on line 3-8
|
||||
(let ((y 3)
|
||||
(unconf (remove-if (lambda (p) (provider-available-p p))
|
||||
(mapcar #'car *provider-configs*)))
|
||||
(conf (count-if #'provider-available-p (mapcar #'car *provider-configs*))))
|
||||
(add-string win (format nil "~a/~a active" conf (length *provider-configs*)) :y 1 :x (- w 14))
|
||||
(when (zerop conf)
|
||||
(add-string win "** No providers configured. Press 1 to set up." :y y :x 1)
|
||||
(incf y))
|
||||
(when unconf
|
||||
(add-string win (format nil "Available: ~{~a~^, ~}"
|
||||
(mapcar (lambda (k) (string-downcase (string k))) unconf)) :y y :x 1)
|
||||
(incf y))
|
||||
(add-string win (format nil "Free tier: openrouter (openrouter.ai), gemma-4 from google") :y y :x 1)
|
||||
(refresh win))))
|
||||
(defun drain-queue ()
|
||||
(bt:with-lock-held (*event-lock*)
|
||||
(let ((evs (nreverse *event-queue*)))
|
||||
(setf *event-queue* nil) evs)))
|
||||
|
||||
(defun config-render-providers (win)
|
||||
"Show all providers with availability status."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(add-string win " Provider Status Key Env" :y 2 :x 1)
|
||||
(loop for entry in *provider-configs*
|
||||
for i from 3
|
||||
do (let* ((provider (car entry))
|
||||
(config (cdr entry))
|
||||
(avail (provider-available-p provider))
|
||||
(key-env (or (getf config :key-env) (getf config :url-env) "--")))
|
||||
(add-string win (format nil " ~:[ ✗~; ✓~] ~20a ~a" avail provider key-env) :y i :x 1))
|
||||
(refresh win)))
|
||||
(defun on-key (&rest args)
|
||||
(let ((ch (car args)))
|
||||
(cond
|
||||
((or (eql ch 10) (eql ch 13) (eq ch :enter)
|
||||
(eql ch #\Newline) (eql ch #\Return))
|
||||
(let ((text (string-trim '(#\Space #\Tab) (input-string))))
|
||||
(when (> (length text) 0)
|
||||
(push text (st :input-history))
|
||||
(setf (st :input-hpos) 0)
|
||||
(setf (st :scroll-offset) 0)
|
||||
(cond
|
||||
;; /eval command: evaluate Lisp form
|
||||
((and (>= (length text) 6)
|
||||
(string-equal (subseq text 0 6) "/eval "))
|
||||
(handler-case
|
||||
(let* ((*read-eval* t)
|
||||
(*package* (find-package :passepartout.gateway-tui))
|
||||
(r (eval (read-from-string (subseq text 6)))))
|
||||
(add-msg :system (format nil "=> ~s" r)))
|
||||
(error (c) (add-msg :system (format nil "=> ✗ ~a" c)))))
|
||||
;; Normal message: send to daemon
|
||||
(t
|
||||
(add-msg :user text)
|
||||
(send-daemon (list :type :event
|
||||
:payload (list :sensor :user-input :text text)))))
|
||||
(setf (st :input-buffer) nil)
|
||||
(setf (st :dirty) (list t t t)))))
|
||||
((or (eql ch 127) (eql ch 8) (eq ch :backspace) (eql ch #\Backspace))
|
||||
(when (st :input-buffer) (pop (st :input-buffer)))
|
||||
(setf (st :dirty) (list nil nil t)))
|
||||
((or (eq ch :up) (eql ch 259))
|
||||
(let* ((h (st :input-history)) (p (st :input-hpos)))
|
||||
(when (and h (< p (1- (length h))))
|
||||
(incf (st :input-hpos))
|
||||
(setf (st :input-buffer)
|
||||
(reverse (coerce (nth (st :input-hpos) h) 'list)))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
((or (eq ch :down) (eql ch 258))
|
||||
(when (> (st :input-hpos) 0)
|
||||
(decf (st :input-hpos))
|
||||
(let ((h (st :input-history)))
|
||||
(setf (st :input-buffer)
|
||||
(if (and h (< (st :input-hpos) (length h)))
|
||||
(reverse (coerce (nth (st :input-hpos) h) 'list))
|
||||
nil))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
((or (eq ch :ppage) (eql ch 339))
|
||||
(incf (st :scroll-offset) 5)
|
||||
(setf (st :dirty) (list nil t nil)))
|
||||
((or (eq ch :npage) (eql ch 338))
|
||||
(setf (st :scroll-offset) (max 0 (- (st :scroll-offset) 5)))
|
||||
(setf (st :dirty) (list nil t nil)))
|
||||
(t
|
||||
(let ((chr (typecase ch
|
||||
(character ch)
|
||||
(integer (code-char ch))
|
||||
(t nil))))
|
||||
(when (and chr (graphic-char-p chr))
|
||||
(push chr (st :input-buffer))
|
||||
(setf (st :dirty) (list nil nil t))))))))
|
||||
|
||||
(defun config-render-cascade (win)
|
||||
"Show current PROVIDER_CASCADE."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(add-string win (format nil " Cascade order: ~{~a~^ → ~}"
|
||||
(mapcar (lambda (k) (string-downcase (string k))) *provider-cascade*)) :y 2 :x 1)
|
||||
(add-string win " Set PROVIDER_CASCADE in .env to reorder." :y 3 :x 1)
|
||||
(refresh win)))
|
||||
(defun on-daemon-msg (msg)
|
||||
(let ((text (getf (getf msg :payload) :text)))
|
||||
(if text
|
||||
(add-msg :agent text)
|
||||
(add-msg :agent (format nil "~a" msg)))))
|
||||
|
||||
(defun config-render-models (win)
|
||||
"Show per-slot model recommendations."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(let ((y 2))
|
||||
(dolist (slot '(:code :chat :plan :background))
|
||||
(add-string win (format nil " ~:@(~a~)" slot) :y y :x 1)
|
||||
(incf y)
|
||||
(let ((desc (cdr (or (assoc slot *slot-descriptions*) '(:fallback . "General purpose")))))
|
||||
(add-string win (subseq desc 0 (min (length desc) (- w 4))) :y y :x 1)
|
||||
(incf y))
|
||||
(dolist (rec (model-explorer-recommend slot))
|
||||
(let ((label (format nil " ~a (~a ctx)"
|
||||
(getf rec :name)
|
||||
(if (getf rec :context) (format nil "~dK" (floor (getf rec :context) 1000)) "?"))))
|
||||
(add-string win (subseq label 0 (min (length label) (- w 4))) :y y :x 1)
|
||||
(incf y))))))
|
||||
(refresh win)))
|
||||
(defun send-daemon (msg)
|
||||
(let ((s (st :stream)))
|
||||
(when (and s (open-stream-p s))
|
||||
(handler-case
|
||||
(progn
|
||||
(format s "~a" (frame-message msg))
|
||||
(finish-output s))
|
||||
(error (c) (log-message "TUI-SEND: ~a" c))))))
|
||||
|
||||
(defun config-render-view (win)
|
||||
"Show current system configuration."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(add-string win (format nil " Active backends: ~a"
|
||||
(loop for k being the hash-keys of *probabilistic-backends* collect k)) :y 2 :x 1)
|
||||
(add-string win (format nil " Cascade: ~{~a~^, ~}"
|
||||
(mapcar (lambda (k) (string-downcase (string k))) *provider-cascade*)) :y 3 :x 1)
|
||||
(add-string win (format nil " Model selector: ~a" (if (boundp '*model-selector*) (symbol-value '*model-selector*) "none")) :y 4 :x 1)
|
||||
(refresh win)))
|
||||
(defun recv-daemon (s)
|
||||
(handler-case
|
||||
(let* ((hdr (make-string 6)) (n 0))
|
||||
(loop while (< n 6)
|
||||
do (let ((ch (read-char s nil)))
|
||||
(unless ch (return-from recv-daemon nil))
|
||||
(setf (char hdr n) ch) (incf n)))
|
||||
(let* ((len (parse-integer hdr :radix 16 :junk-allowed t))
|
||||
(buf (make-string (or len 0))))
|
||||
(when (and len (> len 0))
|
||||
(loop for i from 0 below len
|
||||
do (let ((ch (read-char s nil)))
|
||||
(unless ch (return-from recv-daemon nil))
|
||||
(setf (char buf i) ch)))
|
||||
(let ((*read-eval* nil))
|
||||
(read-from-string buf)))))
|
||||
(error (c) (log-message "TUI-RECV: ~a" c) nil)))
|
||||
|
||||
(defun reader-loop (s)
|
||||
(loop while (and (st :running) (open-stream-p s))
|
||||
do (let ((msg (recv-daemon s)))
|
||||
(when msg (queue-event (list :type :daemon :payload msg))))))
|
||||
|
||||
(defun connect-daemon (&optional (host "127.0.0.1") (port 9105))
|
||||
"Connect to daemon."
|
||||
(handler-case
|
||||
(let ((s (usocket:socket-connect host port :element-type '(unsigned-byte 8))))
|
||||
(setf *stream* (usocket:socket-stream s))
|
||||
(setf *message-queue* nil)
|
||||
(bt:make-thread (lambda () (reader-thread *stream*)) :name "tui-reader")
|
||||
(push (cons :system "* Connected *") *chat-history*)
|
||||
(log-message "Connected to ~a:~a" host port)
|
||||
(let ((s (usocket:socket-connect host port :element-type 'character)))
|
||||
(setf (st :stream) (usocket:socket-stream s) (st :connected) t)
|
||||
(bt:make-thread (lambda () (reader-loop (st :stream))) :name "tui-reader")
|
||||
(add-msg :system "* Connected *")
|
||||
t)
|
||||
(error (c)
|
||||
(push (cons :system (format nil "* Connection failed: ~a *" c)) *chat-history*)
|
||||
(add-msg :system (format nil "* Connection failed: ~a *" c))
|
||||
nil)))
|
||||
|
||||
(defun disconnect-daemon ()
|
||||
"Disconnect from daemon."
|
||||
(when *stream*
|
||||
(ignore-errors (close *stream*))
|
||||
(setf *stream* nil)
|
||||
(push (cons :system "* Disconnected *") *chat-history*)))
|
||||
(when (st :stream)
|
||||
(ignore-errors (close (st :stream)))
|
||||
(setf (st :stream) nil (st :connected) nil)
|
||||
(add-msg :system "* Disconnected *")))
|
||||
|
||||
(defun redraw (sw cw ch iw)
|
||||
(destructuring-bind (sd cd id) (st :dirty)
|
||||
(when sd (view-status sw))
|
||||
(when cd (view-chat cw ch))
|
||||
(when id (view-input iw))
|
||||
(setf (st :dirty) (list nil nil nil))))
|
||||
|
||||
(defun tui-main ()
|
||||
"Entry point for the TUI client."
|
||||
(init-state)
|
||||
(with-screen (scr :input-blocking nil :input-echoing nil :cursor-visible nil)
|
||||
(let* ((h (or (height scr) 24))
|
||||
(w (or (width scr) 80))
|
||||
(input-h 1)
|
||||
(config-h 0)
|
||||
(chat-h (- h input-h config-h 3))
|
||||
(status-win (make-instance 'window :height 3 :width (- w 2) :y 0 :x 1))
|
||||
(chat-win (make-instance 'window :height chat-h :width (- w 2) :y 3 :x 1))
|
||||
(config-win (make-instance 'window :height 0 :width (- w 2) :y (- h input-h config-h 1) :x 1))
|
||||
(input-win (make-instance 'window :height 1 :width (- w 2) :y (- h 1) :x 1)))
|
||||
;; Enable function key processing (must be set per-window)
|
||||
(setf (input-blocking input-win) nil)
|
||||
(setf (function-keys-enabled-p input-win) t)
|
||||
(setf (function-keys-enabled-p chat-win) t)
|
||||
(setf *is-running* t *tui-mode* :chat *input-buffer* nil)
|
||||
(sw (make-instance 'window :height 3 :width (- w 2) :y 0 :x 1))
|
||||
(ch (- h 5))
|
||||
(cw (make-instance 'window :height ch :width (- w 2) :y 3 :x 1))
|
||||
(iw (make-instance 'window :height 1 :width (- w 2) :y (- h 1) :x 1))
|
||||
(swank-port (or (ignore-errors
|
||||
(parse-integer (uiop:getenv "TUI_SWANK_PORT")))
|
||||
4006)))
|
||||
(setf (function-keys-enabled-p iw) t
|
||||
(st :dirty) (list t t t))
|
||||
(connect-daemon)
|
||||
;; First-run: no providers configured → show welcome
|
||||
(when (zerop (hash-table-count *probabilistic-backends*))
|
||||
(push (cons :system "* Welcome to Passepartout! *") *chat-history*)
|
||||
(push (cons :system "No LLM providers configured. Press F2 to open the config panel, then [1] Providers to set up.") *chat-history*)
|
||||
(push (cons :system "For free online models, set OPENROUTER_API_KEY in your .env (or via the TUI).") *chat-history*))
|
||||
(setf *chat-scroll-pos* 0)
|
||||
(status-render status-win)
|
||||
(chat-render chat-win chat-h)
|
||||
(input-render input-win)
|
||||
(loop :while *is-running* :do
|
||||
;; drain message queue
|
||||
(dolist (msg (message-queue-drain))
|
||||
(push (cons :received (let ((p (proto-get msg :payload)))
|
||||
(or (proto-get p :text) (format nil "~a" msg))))
|
||||
*chat-history*))
|
||||
;; handle input
|
||||
(let ((ch (get-char input-win)))
|
||||
;; Start Swank REPL (optional - set TUI_SWANK_PORT=0 to disable)
|
||||
(when (> swank-port 0)
|
||||
(handler-case
|
||||
(progn
|
||||
(ql:quickload :swank :silent t)
|
||||
(funcall (find-symbol "CREATE-SERVER" "SWANK")
|
||||
:port swank-port :dont-close t)
|
||||
(add-msg :system
|
||||
(format nil "* Swank ~d M-x slime-connect *" swank-port)))
|
||||
(error (c)
|
||||
(add-msg :system "* Swank unavailable *"))))
|
||||
;; Main loop
|
||||
(loop while (st :running) do
|
||||
(dolist (ev (drain-queue))
|
||||
(when (eq (getf ev :type) :daemon)
|
||||
(on-daemon-msg (getf ev :payload))))
|
||||
(let ((ch (get-char iw)))
|
||||
(when (and ch (not (equal ch -1)))
|
||||
(log-message "KEY: ~s type=~s" ch (type-of ch))
|
||||
(cond
|
||||
;; F2 (or any integer key >= 265): toggle config panel
|
||||
((and (integerp ch) (>= ch 265) (<= ch 280))
|
||||
(if (eq *tui-mode* :chat)
|
||||
(progn
|
||||
(setf *tui-mode* :config)
|
||||
(setf *config-panel-height* 10)
|
||||
(setf config-h *config-panel-height*)
|
||||
(setf chat-h (- h input-h config-h 3))
|
||||
(resize chat-win chat-h (- w 2))
|
||||
(resize config-win config-h (- w 2))
|
||||
(config-render config-win))
|
||||
(progn
|
||||
(setf *tui-mode* :chat)
|
||||
(setf config-h 0)
|
||||
(setf chat-h (- h input-h 3))
|
||||
(resize chat-win chat-h (- w 2))
|
||||
(resize config-win 0 (- w 2))
|
||||
(chat-render chat-win chat-h)))
|
||||
(status-render status-win))
|
||||
;; Config mode key handling
|
||||
((eq *tui-mode* :config)
|
||||
(cond
|
||||
((or (eql ch #\q) (eql ch #\Q))
|
||||
(setf *tui-mode* :chat config-h 0 chat-h (- h input-h 3))
|
||||
(resize chat-win chat-h (- w 2))
|
||||
(resize config-win 0 (- w 2))
|
||||
(chat-render chat-win chat-h)
|
||||
(status-render status-win))
|
||||
((eql ch #\1)
|
||||
(config-render-providers config-win))
|
||||
((eql ch #\2)
|
||||
(config-render-cascade config-win))
|
||||
((eql ch #\3)
|
||||
(config-render-models config-win))
|
||||
((eql ch #\4)
|
||||
(config-render-view config-win)))
|
||||
(status-render status-win))
|
||||
;; Chat mode key handling
|
||||
(t
|
||||
(cond
|
||||
;; Enter/Return submit
|
||||
((or (eql ch 10) (eql ch 13) (eq ch :enter) (eql ch #\Newline) (eql ch #\Return))
|
||||
(setf *chat-scroll-pos* 0)
|
||||
(input-submit *stream*)
|
||||
(chat-render chat-win chat-h)
|
||||
(input-render input-win)
|
||||
(status-render status-win))
|
||||
;; Backspace
|
||||
((or (eql ch 127) (eql ch 8) (eq ch :backspace) (eql ch #\Backspace))
|
||||
(input-backspace)
|
||||
(input-render input-win))
|
||||
;; Up arrow
|
||||
((or (eq ch :up) (eql ch 259))
|
||||
(let ((prev (input-history-nav :up)))
|
||||
(when prev
|
||||
(setf *input-buffer* (reverse (coerce prev 'list)))
|
||||
(input-render input-win))))
|
||||
;; Down arrow
|
||||
((or (eq ch :down) (eql ch 258))
|
||||
(let ((next (input-history-nav :down)))
|
||||
(if next
|
||||
(setf *input-buffer* (reverse (coerce next 'list)))
|
||||
(setf *input-buffer* nil))
|
||||
(input-render input-win)))
|
||||
;; Page Up
|
||||
((or (eq ch :ppage) (eql ch 339))
|
||||
(setf *chat-scroll-pos* (+ *chat-scroll-pos* 10))
|
||||
(chat-render chat-win chat-h)
|
||||
(status-render status-win))
|
||||
;; Page Down
|
||||
((or (eq ch :npage) (eql ch 338))
|
||||
(setf *chat-scroll-pos* (max 0 (- *chat-scroll-pos* 10)))
|
||||
(chat-render chat-win chat-h)
|
||||
(status-render status-win))
|
||||
;; Printable character
|
||||
((characterp ch)
|
||||
(push ch *input-buffer*)
|
||||
(input-render input-win))
|
||||
;; Integer key code
|
||||
((integerp ch)
|
||||
(let ((converted (code-char ch)))
|
||||
(when (and converted (graphic-char-p converted))
|
||||
(push converted *input-buffer*)
|
||||
(input-render input-win)))))))))
|
||||
(on-key ch)))
|
||||
(redraw sw cw ch iw)
|
||||
(refresh scr)
|
||||
(sleep 0.01))
|
||||
(sleep 0.03))
|
||||
(disconnect-daemon))))
|
||||
|
||||
@@ -1,422 +1,270 @@
|
||||
#+TITLE: Passepartout TUI Client
|
||||
#+PROPERTY: header-args:lisp :tangle ../lisp/gateway-tui.lisp
|
||||
|
||||
* Architectural Intent
|
||||
|
||||
The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It connects to the daemon over TCP, displays messages in a scrollable window, and provides a mini-buffer-style configuration panel (F2) for managing providers, cascade, and model picks.
|
||||
|
||||
** Layout
|
||||
```
|
||||
┌─ Status bar (3 rows) ──────────────────────────────────┐
|
||||
├─ Chat area (expandable/contractable) ──────────────────┤
|
||||
├─ Config mini-buffer (0-12 rows, visible when F2 open) ─┤
|
||||
│ [1] Providers [2] Cascade [3] Models [4] View [q] │
|
||||
│ Provider Status Model │
|
||||
│ openrouter ✓ │
|
||||
│ groq ✓ │
|
||||
│ enter API key for openai: ████████ │
|
||||
├─ Input line (1 row) ───────────────────────────────────┤
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
* Implementation
|
||||
|
||||
** State
|
||||
* Package + Model
|
||||
#+begin_src lisp
|
||||
(defpackage :passepartout.gateway-tui
|
||||
(:use :cl :croatoan :passepartout :usocket :bordeaux-threads)
|
||||
(:export :tui-main))
|
||||
(in-package :passepartout.gateway-tui)
|
||||
|
||||
(declaim (optimize (debug 3) (safety 3) (speed 0)))
|
||||
(defvar *state* nil)
|
||||
(defvar *event-queue* nil)
|
||||
(defvar *event-lock* (bt:make-lock "tui-event-lock"))
|
||||
|
||||
(defvar *stream* nil "TCP stream to daemon")
|
||||
(defvar *input-buffer* nil "Current input line as reversed char list")
|
||||
(defvar *input-history* nil "Sent messages (newest first)")
|
||||
(defvar *input-history-pos* 0 "Position in input history for nav")
|
||||
(defvar *chat-history* nil "List of (direction . text) entries")
|
||||
(defvar *chat-scroll-pos* 0 "How many lines from bottom we scrolled up")
|
||||
(defvar *is-running* t "Main loop flag")
|
||||
(defvar *tui-mode* :chat "Current mode: :chat or :config")
|
||||
(defvar *config-selection* 0 "Selected menu item in config panel")
|
||||
(defvar *config-panel-height* 10 "Height of config panel when open")
|
||||
(defun st (key) (getf *state* key))
|
||||
(defun (setf st) (val key) (setf (getf *state* key) val))
|
||||
|
||||
(defun init-state ()
|
||||
(setf *state*
|
||||
(list :running t :mode :chat :connected nil :stream nil
|
||||
:input-buffer nil :input-history nil :input-hpos 0
|
||||
:messages nil :scroll-offset 0 :dirty (list nil nil nil))))
|
||||
#+end_src
|
||||
|
||||
** Protocol helpers
|
||||
* Helpers
|
||||
#+begin_src lisp
|
||||
(defun send-message (stream msg)
|
||||
"Send a framed s-expression over TCP."
|
||||
(let* ((sexp (format nil "~s" msg))
|
||||
(len (length sexp))
|
||||
(header (format nil "~6,'0x" len)))
|
||||
(write-sequence (babel:string-to-octets (concatenate 'string header sexp)) stream)
|
||||
(force-output stream)))
|
||||
|
||||
|
||||
(defun read-message (stream)
|
||||
"Read a framed s-expression from TCP."
|
||||
(handler-case
|
||||
(let* ((header (make-array 6 :element-type '(unsigned-byte 8)))
|
||||
(read (read-sequence header stream)))
|
||||
(when (< read 6) (return-from read-message nil))
|
||||
(let* ((hex (babel:octets-to-string header))
|
||||
(len (parse-integer hex :radix 16 :junk-allowed t))
|
||||
(buf (make-array len :element-type '(unsigned-byte 8))))
|
||||
(read-sequence buf stream)
|
||||
(read-from-string (babel:octets-to-string buf))))
|
||||
(error () nil)))
|
||||
#+end_src
|
||||
|
||||
** Message queue (for background reader thread)
|
||||
#+begin_src lisp
|
||||
(defvar *message-queue* nil
|
||||
"List of incoming daemon messages from reader thread.")
|
||||
(defvar *message-queue-lock* (bt:make-lock "msg-queue-lock")
|
||||
"Lock for thread-safe queue access.")
|
||||
|
||||
(defun message-queue-push (msg)
|
||||
(bt:acquire-lock *message-queue-lock*)
|
||||
(push msg *message-queue*)
|
||||
(bt:release-lock *message-queue-lock*))
|
||||
|
||||
(defun message-queue-drain ()
|
||||
"Drain all messages from queue. Returns list in FIFO order."
|
||||
(bt:acquire-lock *message-queue-lock*)
|
||||
(let ((msgs (nreverse *message-queue*)))
|
||||
(setf *message-queue* nil)
|
||||
(bt:release-lock *message-queue-lock*)
|
||||
msgs))
|
||||
|
||||
(defun reader-thread (stream)
|
||||
"Background thread: reads daemon messages and enqueues them."
|
||||
(loop while *is-running*
|
||||
do (let ((msg (read-message stream)))
|
||||
(when msg
|
||||
(message-queue-push msg)))))
|
||||
#+end_src
|
||||
|
||||
** Input rendering
|
||||
#+begin_src lisp
|
||||
(defun input-text ()
|
||||
"Convert input buffer to string."
|
||||
(coerce (reverse *input-buffer*) 'string))
|
||||
|
||||
(defun input-backspace ()
|
||||
(when *input-buffer* (pop *input-buffer*)))
|
||||
|
||||
(defun input-submit (stream)
|
||||
"Send current input buffer content to daemon."
|
||||
(let ((text (string-trim '(#\Space) (input-text))))
|
||||
(when (> (length text) 0)
|
||||
(push text *input-history*)
|
||||
(setf *input-history-pos* 0)
|
||||
(send-message stream (list :type :event :payload (list :sensor :user-input :text text)))
|
||||
(push (cons :sent text) *chat-history*)
|
||||
(setf *input-buffer* nil))))
|
||||
|
||||
(defun input-history-nav (dir)
|
||||
"Navigate input history. Returns new value or nil."
|
||||
(let ((len (length *input-history*)))
|
||||
(when (= len 0) (return-from input-history-nav nil))
|
||||
(case dir
|
||||
(:up (if (< *input-history-pos* (1- len))
|
||||
(incf *input-history-pos*)
|
||||
nil))
|
||||
(:down (if (> *input-history-pos* 0)
|
||||
(decf *input-history-pos*)
|
||||
(progn (setf *input-history-pos* 0) nil)))))
|
||||
(when (>= *input-history-pos* 0)
|
||||
(nth *input-history-pos* *input-history*)))
|
||||
#+end_src
|
||||
|
||||
** Display helpers
|
||||
#+begin_src lisp
|
||||
(defun timestamp ()
|
||||
"Return HH:MM timestamp string."
|
||||
(defun now ()
|
||||
(multiple-value-bind (h m) (get-decoded-time)
|
||||
(format nil "~2,'0d:~2,'0d" h m)))
|
||||
|
||||
(defun status-render (win)
|
||||
"Draw the status bar."
|
||||
(let ((h (or (height win) 3))
|
||||
(w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win (format nil " Passepartout ~a [~a] msgs:~a scroll:~a"
|
||||
(if *stream* "● Connected" "○ Disconnected")
|
||||
(ecase *tui-mode* (:chat "CHAT") (:config "CONFIG"))
|
||||
(length *chat-history*)
|
||||
(if (> *chat-scroll-pos* 0) (format nil "~a↑" *chat-scroll-pos*) "0")) :y 1 :x 1)
|
||||
(add-string win (format nil " ~a" (timestamp)) :y 2 :x 1)
|
||||
(refresh win)))
|
||||
(defun input-string ()
|
||||
(coerce (reverse (st :input-buffer)) 'string))
|
||||
|
||||
(defun chat-render (win h)
|
||||
"Draw the chat history within WIN of height H."
|
||||
(defun add-msg (role content)
|
||||
(push (list :role role :content content :time (now)) (st :messages))
|
||||
(setf (st :dirty) (list t t nil)))
|
||||
#+end_src
|
||||
|
||||
* View
|
||||
#+begin_src lisp
|
||||
(defun view-status (win)
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(let* ((max-lines (- h 2))
|
||||
(total (length *chat-history*))
|
||||
(start (max 0 (- total max-lines *chat-scroll-pos*)))
|
||||
(add-string win
|
||||
(format nil " Passepartout ~a [~a] msgs:~a"
|
||||
(if (st :connected) "● Connected" "○ Disconnected")
|
||||
(string-upcase (string (st :mode)))
|
||||
(length (st :messages)))
|
||||
:y 1 :x 1)
|
||||
(add-string win (format nil " ~a" (now)) :y 2 :x 1)
|
||||
(refresh win))
|
||||
|
||||
(defun view-chat (win h)
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(let* ((w (or (width win) 78))
|
||||
(msgs (reverse (st :messages)))
|
||||
(max-lines (- h 2))
|
||||
(total (length msgs))
|
||||
(start (max 0 (- total max-lines (st :scroll-offset))))
|
||||
(y 1))
|
||||
(loop for i from start below total
|
||||
do (let ((entry (nth i *chat-history*)))
|
||||
(when (and (< y (1- h)) (car entry))
|
||||
(let* ((dir (car entry))
|
||||
(text (cdr entry))
|
||||
(prefix (if (eq dir :sent) "⬆" "⬇"))
|
||||
(label (format nil "~a [~a] ~a" prefix (timestamp) text)))
|
||||
(add-string win label :y y :x 1)
|
||||
(incf y))))))
|
||||
while (< y (1- h))
|
||||
do (let ((msg (nth i msgs)))
|
||||
(let* ((role (getf msg :role))
|
||||
(content (or (getf msg :content) ""))
|
||||
(time (or (getf msg :time) ""))
|
||||
(marker (case role (:user ">") (t " ")))
|
||||
(line (format nil "~a [~a] ~a" marker time content)))
|
||||
(add-string win line :y y :x 1 :n (1- w))
|
||||
(incf y)))))
|
||||
(refresh win))
|
||||
|
||||
(defun input-render (win)
|
||||
"Draw the input line."
|
||||
(defun view-input (win)
|
||||
(clear win)
|
||||
(add-string win (input-text) :y 0 :x 0)
|
||||
(let* ((text (input-string))
|
||||
(w (or (width win) 78))
|
||||
(clip (min (length text) (1- w))))
|
||||
(add-string win text :y 0 :x 0 :n clip)
|
||||
(setf (cursor-position win) (list 0 clip)))
|
||||
(refresh win))
|
||||
#+end_src
|
||||
|
||||
** Config panel
|
||||
* Event Queue
|
||||
#+begin_src lisp
|
||||
(defun config-provider-line (provider)
|
||||
"Return formatted provider line: ' ✓ openrouter' or ' ✗ openrouter'."
|
||||
(format nil " ~:[✗~;✓~] ~(~a~)" (provider-available-p provider) provider))
|
||||
(defun queue-event (ev)
|
||||
(bt:with-lock-held (*event-lock*) (push ev *event-queue*)))
|
||||
|
||||
(defun config-render (win)
|
||||
"Draw the config mini-buffer panel with menu and provider overview."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(add-string win (format nil " Set provider: ~a"
|
||||
(mapcar #'config-provider-line *provider-cascade*)) :y 2 :x 1)
|
||||
;; Show unconfigured but available providers on line 3-8
|
||||
(let ((y 3)
|
||||
(unconf (remove-if (lambda (p) (provider-available-p p))
|
||||
(mapcar #'car *provider-configs*)))
|
||||
(conf (count-if #'provider-available-p (mapcar #'car *provider-configs*))))
|
||||
(add-string win (format nil "~a/~a active" conf (length *provider-configs*)) :y 1 :x (- w 14))
|
||||
(when (zerop conf)
|
||||
(add-string win "** No providers configured. Press 1 to set up." :y y :x 1)
|
||||
(incf y))
|
||||
(when unconf
|
||||
(add-string win (format nil "Available: ~{~a~^, ~}"
|
||||
(mapcar (lambda (k) (string-downcase (string k))) unconf)) :y y :x 1)
|
||||
(incf y))
|
||||
(add-string win (format nil "Free tier: openrouter (openrouter.ai), gemma-4 from google") :y y :x 1)
|
||||
(refresh win))))
|
||||
|
||||
(defun config-render-providers (win)
|
||||
"Show all providers with availability status."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(add-string win " Provider Status Key Env" :y 2 :x 1)
|
||||
(loop for entry in *provider-configs*
|
||||
for i from 3
|
||||
do (let* ((provider (car entry))
|
||||
(config (cdr entry))
|
||||
(avail (provider-available-p provider))
|
||||
(key-env (or (getf config :key-env) (getf config :url-env) "--")))
|
||||
(add-string win (format nil " ~:[ ✗~; ✓~] ~20a ~a" avail provider key-env) :y i :x 1))
|
||||
(refresh win)))
|
||||
|
||||
(defun config-render-cascade (win)
|
||||
"Show current PROVIDER_CASCADE."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(add-string win (format nil " Cascade order: ~{~a~^ → ~}"
|
||||
(mapcar (lambda (k) (string-downcase (string k))) *provider-cascade*)) :y 2 :x 1)
|
||||
(add-string win " Set PROVIDER_CASCADE in .env to reorder." :y 3 :x 1)
|
||||
(refresh win)))
|
||||
|
||||
(defun config-render-models (win)
|
||||
"Show per-slot model recommendations."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(let ((y 2))
|
||||
(dolist (slot '(:code :chat :plan :background))
|
||||
(add-string win (format nil " ~:@(~a~)" slot) :y y :x 1)
|
||||
(incf y)
|
||||
(let ((desc (cdr (or (assoc slot *slot-descriptions*) '(:fallback . "General purpose")))))
|
||||
(add-string win (subseq desc 0 (min (length desc) (- w 4))) :y y :x 1)
|
||||
(incf y))
|
||||
(dolist (rec (model-explorer-recommend slot))
|
||||
(let ((label (format nil " ~a (~a ctx)"
|
||||
(getf rec :name)
|
||||
(if (getf rec :context) (format nil "~dK" (floor (getf rec :context) 1000)) "?"))))
|
||||
(add-string win (subseq label 0 (min (length label) (- w 4))) :y y :x 1)
|
||||
(incf y))))))
|
||||
(refresh win)))
|
||||
|
||||
(defun config-render-view (win)
|
||||
"Show current system configuration."
|
||||
(let ((w (or (width win) 78)))
|
||||
(clear win)
|
||||
(box win 0 0)
|
||||
(add-string win "[1] Providers [2] Cascade [3] Models [4] View [q] Back" :y 1 :x 1)
|
||||
(add-string win (format nil " Active backends: ~a"
|
||||
(loop for k being the hash-keys of *probabilistic-backends* collect k)) :y 2 :x 1)
|
||||
(add-string win (format nil " Cascade: ~{~a~^, ~}"
|
||||
(mapcar (lambda (k) (string-downcase (string k))) *provider-cascade*)) :y 3 :x 1)
|
||||
(add-string win (format nil " Model selector: ~a" (if (boundp '*model-selector*) (symbol-value '*model-selector*) "none")) :y 4 :x 1)
|
||||
(refresh win)))
|
||||
(defun drain-queue ()
|
||||
(bt:with-lock-held (*event-lock*)
|
||||
(let ((evs (nreverse *event-queue*)))
|
||||
(setf *event-queue* nil) evs)))
|
||||
#+end_src
|
||||
|
||||
** Connection
|
||||
* Event Handlers
|
||||
#+begin_src lisp
|
||||
(defun on-key (&rest args)
|
||||
(let ((ch (car args)))
|
||||
(cond
|
||||
((or (eql ch 10) (eql ch 13) (eq ch :enter)
|
||||
(eql ch #\Newline) (eql ch #\Return))
|
||||
(let ((text (string-trim '(#\Space #\Tab) (input-string))))
|
||||
(when (> (length text) 0)
|
||||
(push text (st :input-history))
|
||||
(setf (st :input-hpos) 0)
|
||||
(setf (st :scroll-offset) 0)
|
||||
(cond
|
||||
;; /eval command: evaluate Lisp form
|
||||
((and (>= (length text) 6)
|
||||
(string-equal (subseq text 0 6) "/eval "))
|
||||
(handler-case
|
||||
(let* ((*read-eval* t)
|
||||
(*package* (find-package :passepartout.gateway-tui))
|
||||
(r (eval (read-from-string (subseq text 6)))))
|
||||
(add-msg :system (format nil "=> ~s" r)))
|
||||
(error (c) (add-msg :system (format nil "=> ✗ ~a" c)))))
|
||||
;; Normal message: send to daemon
|
||||
(t
|
||||
(add-msg :user text)
|
||||
(send-daemon (list :type :event
|
||||
:payload (list :sensor :user-input :text text)))))
|
||||
(setf (st :input-buffer) nil)
|
||||
(setf (st :dirty) (list t t t)))))
|
||||
((or (eql ch 127) (eql ch 8) (eq ch :backspace) (eql ch #\Backspace))
|
||||
(when (st :input-buffer) (pop (st :input-buffer)))
|
||||
(setf (st :dirty) (list nil nil t)))
|
||||
((or (eq ch :up) (eql ch 259))
|
||||
(let* ((h (st :input-history)) (p (st :input-hpos)))
|
||||
(when (and h (< p (1- (length h))))
|
||||
(incf (st :input-hpos))
|
||||
(setf (st :input-buffer)
|
||||
(reverse (coerce (nth (st :input-hpos) h) 'list)))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
((or (eq ch :down) (eql ch 258))
|
||||
(when (> (st :input-hpos) 0)
|
||||
(decf (st :input-hpos))
|
||||
(let ((h (st :input-history)))
|
||||
(setf (st :input-buffer)
|
||||
(if (and h (< (st :input-hpos) (length h)))
|
||||
(reverse (coerce (nth (st :input-hpos) h) 'list))
|
||||
nil))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
((or (eq ch :ppage) (eql ch 339))
|
||||
(incf (st :scroll-offset) 5)
|
||||
(setf (st :dirty) (list nil t nil)))
|
||||
((or (eq ch :npage) (eql ch 338))
|
||||
(setf (st :scroll-offset) (max 0 (- (st :scroll-offset) 5)))
|
||||
(setf (st :dirty) (list nil t nil)))
|
||||
(t
|
||||
(let ((chr (typecase ch
|
||||
(character ch)
|
||||
(integer (code-char ch))
|
||||
(t nil))))
|
||||
(when (and chr (graphic-char-p chr))
|
||||
(push chr (st :input-buffer))
|
||||
(setf (st :dirty) (list nil nil t))))))))
|
||||
|
||||
(defun on-daemon-msg (msg)
|
||||
(let ((text (getf (getf msg :payload) :text)))
|
||||
(if text
|
||||
(add-msg :agent text)
|
||||
(add-msg :agent (format nil "~a" msg)))))
|
||||
#+end_src
|
||||
|
||||
* Daemon I/O
|
||||
#+begin_src lisp
|
||||
(defun send-daemon (msg)
|
||||
(let ((s (st :stream)))
|
||||
(when (and s (open-stream-p s))
|
||||
(handler-case
|
||||
(progn
|
||||
(format s "~a" (frame-message msg))
|
||||
(finish-output s))
|
||||
(error (c) (log-message "TUI-SEND: ~a" c))))))
|
||||
|
||||
(defun recv-daemon (s)
|
||||
(handler-case
|
||||
(let* ((hdr (make-string 6)) (n 0))
|
||||
(loop while (< n 6)
|
||||
do (let ((ch (read-char s nil)))
|
||||
(unless ch (return-from recv-daemon nil))
|
||||
(setf (char hdr n) ch) (incf n)))
|
||||
(let* ((len (parse-integer hdr :radix 16 :junk-allowed t))
|
||||
(buf (make-string (or len 0))))
|
||||
(when (and len (> len 0))
|
||||
(loop for i from 0 below len
|
||||
do (let ((ch (read-char s nil)))
|
||||
(unless ch (return-from recv-daemon nil))
|
||||
(setf (char buf i) ch)))
|
||||
(let ((*read-eval* nil))
|
||||
(read-from-string buf)))))
|
||||
(error (c) (log-message "TUI-RECV: ~a" c) nil)))
|
||||
|
||||
(defun reader-loop (s)
|
||||
(loop while (and (st :running) (open-stream-p s))
|
||||
do (let ((msg (recv-daemon s)))
|
||||
(when msg (queue-event (list :type :daemon :payload msg))))))
|
||||
#+end_src
|
||||
|
||||
* Connection
|
||||
#+begin_src lisp
|
||||
(defun connect-daemon (&optional (host "127.0.0.1") (port 9105))
|
||||
"Connect to daemon."
|
||||
(handler-case
|
||||
(let ((s (usocket:socket-connect host port :element-type '(unsigned-byte 8))))
|
||||
(setf *stream* (usocket:socket-stream s))
|
||||
(setf *message-queue* nil)
|
||||
(bt:make-thread (lambda () (reader-thread *stream*)) :name "tui-reader")
|
||||
(push (cons :system "* Connected *") *chat-history*)
|
||||
(log-message "Connected to ~a:~a" host port)
|
||||
(let ((s (usocket:socket-connect host port :element-type 'character)))
|
||||
(setf (st :stream) (usocket:socket-stream s) (st :connected) t)
|
||||
(bt:make-thread (lambda () (reader-loop (st :stream))) :name "tui-reader")
|
||||
(add-msg :system "* Connected *")
|
||||
t)
|
||||
(error (c)
|
||||
(push (cons :system (format nil "* Connection failed: ~a *" c)) *chat-history*)
|
||||
(add-msg :system (format nil "* Connection failed: ~a *" c))
|
||||
nil)))
|
||||
|
||||
(defun disconnect-daemon ()
|
||||
"Disconnect from daemon."
|
||||
(when *stream*
|
||||
(ignore-errors (close *stream*))
|
||||
(setf *stream* nil)
|
||||
(push (cons :system "* Disconnected *") *chat-history*)))
|
||||
(when (st :stream)
|
||||
(ignore-errors (close (st :stream)))
|
||||
(setf (st :stream) nil (st :connected) nil)
|
||||
(add-msg :system "* Disconnected *")))
|
||||
#+end_src
|
||||
|
||||
** Main event loop
|
||||
* Redraw
|
||||
#+begin_src lisp
|
||||
(defun redraw (sw cw ch iw)
|
||||
(destructuring-bind (sd cd id) (st :dirty)
|
||||
(when sd (view-status sw))
|
||||
(when cd (view-chat cw ch))
|
||||
(when id (view-input iw))
|
||||
(setf (st :dirty) (list nil nil nil))))
|
||||
#+end_src
|
||||
|
||||
* Main
|
||||
#+begin_src lisp
|
||||
(defun tui-main ()
|
||||
"Entry point for the TUI client."
|
||||
(init-state)
|
||||
(with-screen (scr :input-blocking nil :input-echoing nil :cursor-visible nil)
|
||||
(let* ((h (or (height scr) 24))
|
||||
(w (or (width scr) 80))
|
||||
(input-h 1)
|
||||
(config-h 0)
|
||||
(chat-h (- h input-h config-h 3))
|
||||
(status-win (make-instance 'window :height 3 :width (- w 2) :y 0 :x 1))
|
||||
(chat-win (make-instance 'window :height chat-h :width (- w 2) :y 3 :x 1))
|
||||
(config-win (make-instance 'window :height 0 :width (- w 2) :y (- h input-h config-h 1) :x 1))
|
||||
(input-win (make-instance 'window :height 1 :width (- w 2) :y (- h 1) :x 1)))
|
||||
;; Enable function key processing (must be set per-window)
|
||||
(setf (input-blocking input-win) nil)
|
||||
(setf (function-keys-enabled-p input-win) t)
|
||||
(setf (function-keys-enabled-p chat-win) t)
|
||||
(setf *is-running* t *tui-mode* :chat *input-buffer* nil)
|
||||
(sw (make-instance 'window :height 3 :width (- w 2) :y 0 :x 1))
|
||||
(ch (- h 5))
|
||||
(cw (make-instance 'window :height ch :width (- w 2) :y 3 :x 1))
|
||||
(iw (make-instance 'window :height 1 :width (- w 2) :y (- h 1) :x 1))
|
||||
(swank-port (or (ignore-errors
|
||||
(parse-integer (uiop:getenv "TUI_SWANK_PORT")))
|
||||
4006)))
|
||||
(setf (function-keys-enabled-p iw) t
|
||||
(st :dirty) (list t t t))
|
||||
(connect-daemon)
|
||||
;; First-run: no providers configured → show welcome
|
||||
(when (zerop (hash-table-count *probabilistic-backends*))
|
||||
(push (cons :system "* Welcome to Passepartout! *") *chat-history*)
|
||||
(push (cons :system "No LLM providers configured. Press F2 to open the config panel, then [1] Providers to set up.") *chat-history*)
|
||||
(push (cons :system "For free online models, set OPENROUTER_API_KEY in your .env (or via the TUI).") *chat-history*))
|
||||
(setf *chat-scroll-pos* 0)
|
||||
(status-render status-win)
|
||||
(chat-render chat-win chat-h)
|
||||
(input-render input-win)
|
||||
(loop :while *is-running* :do
|
||||
;; drain message queue
|
||||
(dolist (msg (message-queue-drain))
|
||||
(push (cons :received (let ((p (proto-get msg :payload)))
|
||||
(or (proto-get p :text) (format nil "~a" msg))))
|
||||
*chat-history*))
|
||||
;; handle input
|
||||
(let ((ch (get-char input-win)))
|
||||
;; Start Swank REPL (optional - set TUI_SWANK_PORT=0 to disable)
|
||||
(when (> swank-port 0)
|
||||
(handler-case
|
||||
(progn
|
||||
(ql:quickload :swank :silent t)
|
||||
(funcall (find-symbol "CREATE-SERVER" "SWANK")
|
||||
:port swank-port :dont-close t)
|
||||
(add-msg :system
|
||||
(format nil "* Swank ~d M-x slime-connect *" swank-port)))
|
||||
(error (c)
|
||||
(add-msg :system "* Swank unavailable *"))))
|
||||
;; Main loop
|
||||
(loop while (st :running) do
|
||||
(dolist (ev (drain-queue))
|
||||
(when (eq (getf ev :type) :daemon)
|
||||
(on-daemon-msg (getf ev :payload))))
|
||||
(let ((ch (get-char iw)))
|
||||
(when (and ch (not (equal ch -1)))
|
||||
(log-message "KEY: ~s type=~s" ch (type-of ch))
|
||||
(cond
|
||||
;; F2 (or any integer key >= 265): toggle config panel
|
||||
((and (integerp ch) (>= ch 265) (<= ch 280))
|
||||
(if (eq *tui-mode* :chat)
|
||||
(progn
|
||||
(setf *tui-mode* :config)
|
||||
(setf *config-panel-height* 10)
|
||||
(setf config-h *config-panel-height*)
|
||||
(setf chat-h (- h input-h config-h 3))
|
||||
(resize chat-win chat-h (- w 2))
|
||||
(resize config-win config-h (- w 2))
|
||||
(config-render config-win))
|
||||
(progn
|
||||
(setf *tui-mode* :chat)
|
||||
(setf config-h 0)
|
||||
(setf chat-h (- h input-h 3))
|
||||
(resize chat-win chat-h (- w 2))
|
||||
(resize config-win 0 (- w 2))
|
||||
(chat-render chat-win chat-h)))
|
||||
(status-render status-win))
|
||||
;; Config mode key handling
|
||||
((eq *tui-mode* :config)
|
||||
(cond
|
||||
((or (eql ch #\q) (eql ch #\Q))
|
||||
(setf *tui-mode* :chat config-h 0 chat-h (- h input-h 3))
|
||||
(resize chat-win chat-h (- w 2))
|
||||
(resize config-win 0 (- w 2))
|
||||
(chat-render chat-win chat-h)
|
||||
(status-render status-win))
|
||||
((eql ch #\1)
|
||||
(config-render-providers config-win))
|
||||
((eql ch #\2)
|
||||
(config-render-cascade config-win))
|
||||
((eql ch #\3)
|
||||
(config-render-models config-win))
|
||||
((eql ch #\4)
|
||||
(config-render-view config-win)))
|
||||
(status-render status-win))
|
||||
;; Chat mode key handling
|
||||
(t
|
||||
(cond
|
||||
;; Enter/Return submit
|
||||
((or (eql ch 10) (eql ch 13) (eq ch :enter) (eql ch #\Newline) (eql ch #\Return))
|
||||
(setf *chat-scroll-pos* 0)
|
||||
(input-submit *stream*)
|
||||
(chat-render chat-win chat-h)
|
||||
(input-render input-win)
|
||||
(status-render status-win))
|
||||
;; Backspace
|
||||
((or (eql ch 127) (eql ch 8) (eq ch :backspace) (eql ch #\Backspace))
|
||||
(input-backspace)
|
||||
(input-render input-win))
|
||||
;; Up arrow
|
||||
((or (eq ch :up) (eql ch 259))
|
||||
(let ((prev (input-history-nav :up)))
|
||||
(when prev
|
||||
(setf *input-buffer* (reverse (coerce prev 'list)))
|
||||
(input-render input-win))))
|
||||
;; Down arrow
|
||||
((or (eq ch :down) (eql ch 258))
|
||||
(let ((next (input-history-nav :down)))
|
||||
(if next
|
||||
(setf *input-buffer* (reverse (coerce next 'list)))
|
||||
(setf *input-buffer* nil))
|
||||
(input-render input-win)))
|
||||
;; Page Up
|
||||
((or (eq ch :ppage) (eql ch 339))
|
||||
(setf *chat-scroll-pos* (+ *chat-scroll-pos* 10))
|
||||
(chat-render chat-win chat-h)
|
||||
(status-render status-win))
|
||||
;; Page Down
|
||||
((or (eq ch :npage) (eql ch 338))
|
||||
(setf *chat-scroll-pos* (max 0 (- *chat-scroll-pos* 10)))
|
||||
(chat-render chat-win chat-h)
|
||||
(status-render status-win))
|
||||
;; Printable character
|
||||
((characterp ch)
|
||||
(push ch *input-buffer*)
|
||||
(input-render input-win))
|
||||
;; Integer key code
|
||||
((integerp ch)
|
||||
(let ((converted (code-char ch)))
|
||||
(when (and converted (graphic-char-p converted))
|
||||
(push converted *input-buffer*)
|
||||
(input-render input-win)))))))))
|
||||
(on-key ch)))
|
||||
(redraw sw cw ch iw)
|
||||
(refresh scr)
|
||||
(sleep 0.01))
|
||||
(sleep 0.03))
|
||||
(disconnect-daemon))))
|
||||
#+end_src
|
||||
|
||||
Reference in New Issue
Block a user