TUI config panel: full implementation, working through tmux

- Package: passepartout.gateway-tui (uses croatoan, usocket, bordeaux-threads)
- Config panel with 4 sections: Providers, Cascade, Models, View
- Config-render functions for each section with live provider data
- Fixed add-string keyword argument order (was positional)
- Added function-keys-enabled-p for arrow key handling
- Fixed config-render-models balance (missing close paren)
- Fixed config-render balance (missing close paren)
- Added providers-configured-p to core-loop
- First-run welcome messages when no providers configured
- Daemon-side: WELCOME log on empty *probabilistic-backends*
Known: F2 function key needs terminal-level keypad mode; /config typed command works
This commit is contained in:
2026-05-04 11:09:22 -04:00
parent 3bb797ab9e
commit 31e53e675e
3 changed files with 104 additions and 85 deletions

View File

@@ -1,4 +1,7 @@
(in-package :passepartout)
(defpackage :passepartout.gateway-tui
(:use :cl :croatoan :passepartout :usocket :bordeaux-threads)
(:export :tui-main))
(in-package :passepartout.gateway-tui)
(defvar *stream* nil "TCP stream to daemon")
(defvar *input-buffer* nil "Current input line as reversed char list")
@@ -100,12 +103,12 @@
(w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 (format nil " Passepartout ~a [~a] msgs:~a scroll:~a"
(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")))
(add-string win 2 1 (format nil " ~a" (timestamp)))
(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 chat-render (win h)
@@ -123,14 +126,14 @@
(text (cdr entry))
(prefix (if (eq dir :sent) "⬆" "⬇"))
(label (format nil "~a [~a] ~a" prefix (timestamp) text)))
(add-string win 1 y label)
(add-string win label :y 1 :x y)
(incf y))))))
(refresh win))
(defun input-render (win)
"Draw the input line."
(clear win)
(add-string win 0 0 (input-text))
(add-string win (input-text) :y 0 :x 0)
(refresh win))
(defun config-provider-line (provider)
@@ -142,39 +145,39 @@
(let ((w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(add-string win 2 1 (format nil " Set provider: ~a"
(mapcar #'config-provider-line *provider-cascade*)))
(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 1 (- w 14) (format nil "~a/~a active" conf (length *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 y 1 "** No providers configured. Press 1 to set up.")
(add-string win "** No providers configured. Press 1 to set up." :y y :x 1)
(incf y))
(when unconf
(add-string win y 1 (format nil "Available: ~{~a~^, ~}"
(mapcar (lambda (k) (string-downcase (string k))) 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 y 1 (format nil "Free tier: openrouter (openrouter.ai), gemma-4 from google"))
(refresh win)))
(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 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(add-string win 2 1 " Provider Status Key Env")
(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 i 1 (format nil " ~:[ ✗~; ✓~] ~20@(~a~) ~a" avail provider key-env))))
(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)
@@ -182,10 +185,10 @@
(let ((w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(add-string win 2 1 (format nil " Cascade order: ~{~a~^ → ~}"
(mapcar (lambda (k) (string-downcase (string k))) *provider-cascade*)))
(add-string win 3 1 " Set PROVIDER_CASCADE in .env to reorder.")
(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)
@@ -193,19 +196,19 @@
(let ((w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(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 y 1 (format nil " ~:@(~a~)" slot))
(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 y 1 (subseq desc 0 (min (length desc) (- w 4))))
(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 y 1 (subseq label 0 (min (length label) (- w 4))))
(add-string win (subseq label 0 (min (length label) (- w 4))) :y y :x 1)
(incf y))))))
(refresh win)))
@@ -214,12 +217,12 @@
(let ((w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(add-string win 2 1 (format nil " Active backends: ~a"
(loop for k being the hash-keys of *probabilistic-backends* collect k)))
(add-string win 3 1 (format nil " Cascade: ~{~a~^, ~}"
(mapcar (lambda (k) (string-downcase (string k))) *provider-cascade*)))
(add-string win 4 1 (format nil " Model selector: ~a" (if (boundp '*model-selector*) (symbol-value '*model-selector*) "none")))
(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 connect-daemon (&optional (host "127.0.0.1") (port 9105))
@@ -254,7 +257,11 @@
(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)))
(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)
(connect-daemon)
;; First-run: no providers configured → show welcome
@@ -273,11 +280,12 @@
(or (proto-get p :text) (format nil "~a" msg))))
*chat-history*))
;; handle input
(let ((ch (get-char input-win)))
(let ((ch (get-char input-win :timeout 0.1)))
(when (and ch (not (equal ch -1)))
(log-message "KEY: ~s type=~s" ch (type-of ch))
(cond
;; F2: toggle config panel
((or (eq ch :f2) (and (integerp ch) (= ch 265)))
;; 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)

View File

@@ -23,7 +23,10 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
** State
#+begin_src lisp
(in-package :passepartout)
(defpackage :passepartout.gateway-tui
(:use :cl :croatoan :passepartout :usocket :bordeaux-threads)
(:export :tui-main))
(in-package :passepartout.gateway-tui)
(defvar *stream* nil "TCP stream to daemon")
(defvar *input-buffer* nil "Current input line as reversed char list")
@@ -137,12 +140,12 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
(w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 (format nil " Passepartout ~a [~a] msgs:~a scroll:~a"
(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")))
(add-string win 2 1 (format nil " ~a" (timestamp)))
(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 chat-render (win h)
@@ -160,14 +163,14 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
(text (cdr entry))
(prefix (if (eq dir :sent) "⬆" "⬇"))
(label (format nil "~a [~a] ~a" prefix (timestamp) text)))
(add-string win 1 y label)
(add-string win label :y 1 :x y)
(incf y))))))
(refresh win))
(defun input-render (win)
"Draw the input line."
(clear win)
(add-string win 0 0 (input-text))
(add-string win (input-text) :y 0 :x 0)
(refresh win))
#+end_src
@@ -182,39 +185,39 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
(let ((w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(add-string win 2 1 (format nil " Set provider: ~a"
(mapcar #'config-provider-line *provider-cascade*)))
(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 1 (- w 14) (format nil "~a/~a active" conf (length *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 y 1 "** No providers configured. Press 1 to set up.")
(add-string win "** No providers configured. Press 1 to set up." :y y :x 1)
(incf y))
(when unconf
(add-string win y 1 (format nil "Available: ~{~a~^, ~}"
(mapcar (lambda (k) (string-downcase (string k))) 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 y 1 (format nil "Free tier: openrouter (openrouter.ai), gemma-4 from google"))
(refresh win)))
(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 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(add-string win 2 1 " Provider Status Key Env")
(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 i 1 (format nil " ~:[ ✗~; ✓~] ~20@(~a~) ~a" avail provider key-env))))
(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)
@@ -222,10 +225,10 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
(let ((w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(add-string win 2 1 (format nil " Cascade order: ~{~a~^ → ~}"
(mapcar (lambda (k) (string-downcase (string k))) *provider-cascade*)))
(add-string win 3 1 " Set PROVIDER_CASCADE in .env to reorder.")
(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)
@@ -233,19 +236,19 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
(let ((w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(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 y 1 (format nil " ~:@(~a~)" slot))
(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 y 1 (subseq desc 0 (min (length desc) (- w 4))))
(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 y 1 (subseq label 0 (min (length label) (- w 4))))
(add-string win (subseq label 0 (min (length label) (- w 4))) :y y :x 1)
(incf y))))))
(refresh win)))
@@ -254,12 +257,12 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
(let ((w (or (width win) 78)))
(clear win)
(box win 0 0)
(add-string win 1 1 "[1] Providers [2] Cascade [3] Models [4] View [q] Back")
(add-string win 2 1 (format nil " Active backends: ~a"
(loop for k being the hash-keys of *probabilistic-backends* collect k)))
(add-string win 3 1 (format nil " Cascade: ~{~a~^, ~}"
(mapcar (lambda (k) (string-downcase (string k))) *provider-cascade*)))
(add-string win 4 1 (format nil " Model selector: ~a" (if (boundp '*model-selector*) (symbol-value '*model-selector*) "none")))
(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)))
#+end_src
@@ -300,7 +303,11 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
(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)))
(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)
(connect-daemon)
;; First-run: no providers configured → show welcome
@@ -319,11 +326,12 @@ The TUI Client is a Croatoan-based ncurses chat interface for Passepartout. It c
(or (proto-get p :text) (format nil "~a" msg))))
*chat-history*))
;; handle input
(let ((ch (get-char input-win)))
(let ((ch (get-char input-win :timeout 0.1)))
(when (and ch (not (equal ch -1)))
(log-message "KEY: ~s type=~s" ch (type-of ch))
(cond
;; F2: toggle config panel
((or (eq ch :f2) (and (integerp ch) (= ch 265)))
;; 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)

View File

@@ -404,7 +404,10 @@ case "$COMMAND" in
exec sbcl \
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
--eval '(ql:quickload :passepartout/tui)' \
--eval '(passepartout.gateway-tui:main)'
--eval '(in-package :passepartout)' \
--eval "(load (format nil \"~alisp/system-model-provider.lisp\" (truename \"$PASSEPARTOUT_DATA_DIR/\")))" \
--eval "(load (format nil \"~alisp/system-model-explorer.lisp\" (truename \"$PASSEPARTOUT_DATA_DIR/\")))" \
--eval '(passepartout.gateway-tui:tui-main)'
;;
gateway)
SUBCMD=$1; PLATFORM=$2; TOKEN=$3