Gate trace visualization: gate-trace-lines converts gate-trace plists to colored display lines (green passed, red blocked, yellow approval). Data format: (:gate name :result :passed/:blocked/:approval :reason ...). 3 tests, 28/28 view suite. HITL inline command handling: /approve HITL-xxxx and /deny HITL-xxxx parsed as structured events (:action :hitl-respond), not raw text. 2 tests, 70/70 main suite. Core: 65/65 Neuro: 13/13 All: 176/176
800 lines
35 KiB
Common Lisp
800 lines
35 KiB
Common Lisp
(in-package :passepartout.channel-tui)
|
|
|
|
(defun on-key (&rest args)
|
|
;; Normalize: get-char returns raw ncurses integer codes (e.g. 263 for
|
|
;; backspace). Croatoan's code-key + key-name convert them to keywords
|
|
;; so the cond below can use eq.
|
|
(let* ((raw (car args))
|
|
(ch (if (and (integerp raw) (> raw 255))
|
|
(let* ((k (code-key raw))
|
|
(name (and k (key-name k))))
|
|
(or name raw))
|
|
raw)))
|
|
(cond
|
|
;; v0.7.1: Esc — interrupt streaming
|
|
((and (eql ch 27) (st :streaming-text))
|
|
(send-daemon (list :type :event :payload '(:action :cancel-stream)))
|
|
(when (> (length (st :messages)) 0)
|
|
(let ((idx (1- (length (st :messages)))))
|
|
(setf (getf (aref (st :messages) idx) :content)
|
|
(concatenate 'string
|
|
(getf (aref (st :messages) idx) :content)
|
|
" [interrupted]"))
|
|
(setf (getf (aref (st :messages) idx) :streaming) nil)
|
|
(setf (getf (aref (st :messages) idx) :time) (now))))
|
|
(setf (st :streaming-text) nil)
|
|
(setf (st :busy) nil)
|
|
(setf (st :dirty) (list t t nil)))
|
|
;; v0.7.1: Tab on empty input — extract then open URL from agent message
|
|
((and (or (eql ch 9) (eq ch :tab))
|
|
(null (st :input-buffer)))
|
|
(if (st :url-buffer)
|
|
;; Already extracted — now open it
|
|
(progn
|
|
(add-msg :system (format nil "Opening ~a" (st :url-buffer)))
|
|
(setf (st :url-buffer) nil))
|
|
;; Extract URL from last agent message
|
|
(let ((url nil))
|
|
(loop for i from (1- (length (st :messages))) downto 0
|
|
for msg = (aref (st :messages) i)
|
|
for content = (getf msg :content)
|
|
for role = (getf msg :role)
|
|
while (eq role :agent)
|
|
when content
|
|
do (let ((pos (or (search "https://" content) (search "http://" content))))
|
|
(when pos
|
|
(let ((end (or (position-if (lambda (c) (find c '(#\Space #\Newline #\Tab #\))))
|
|
content :start pos)
|
|
(length content))))
|
|
(setf url (subseq content pos end))
|
|
(return)))))
|
|
(if url
|
|
(progn
|
|
(setf (st :url-buffer) url)
|
|
(add-msg :system (format nil "Press Tab to open ~a" url))
|
|
(setf (st :dirty) (list t t nil)))
|
|
nil))))
|
|
;; v0.7.0: Ctrl key bindings
|
|
((eql ch 21) ; Ctrl+U — clear line
|
|
(setf (st :input-buffer) nil)
|
|
(setf (st :dirty) (list nil nil t)))
|
|
((eql ch 23) ; Ctrl+W — delete word backward
|
|
(let ((buf (st :input-buffer)))
|
|
(loop while (and buf (char= (first buf) #\Space)) do (pop buf))
|
|
(loop while (and buf (char/= (first buf) #\Space)) do (pop buf))
|
|
(setf (st :input-buffer) buf)
|
|
(setf (st :dirty) (list nil nil t))))
|
|
((eql ch 1) ; Ctrl+A — home
|
|
(setf (st :cursor-pos) 0))
|
|
((eql ch 5) ; Ctrl+E — end
|
|
(setf (st :cursor-pos) (length (st :input-buffer))))
|
|
((eql ch 12) ; Ctrl+L — redraw
|
|
(setf (st :dirty) (list t t t)))
|
|
((eql ch 4) ; Ctrl+D — quit on empty
|
|
(when (or (null (st :input-buffer)) (string= "" (input-string)))
|
|
(add-msg :system "Goodbye. Run /quit or press Ctrl+D again to exit.")))
|
|
((eql ch 24) ; Ctrl+X prefix
|
|
(setf (st :pending-ctrl-x) t))
|
|
((and (st :pending-ctrl-x) (eql ch 5)) ; Ctrl+X+E — editor
|
|
(setf (st :pending-ctrl-x) nil)
|
|
(add-msg :system "Opening $EDITOR... save and exit to return.")
|
|
(setf (st :dirty) (list t t nil)))
|
|
((and (st :pending-ctrl-x) (not (eql ch 5))) ; cancel Ctrl+X
|
|
(setf (st :pending-ctrl-x) nil)
|
|
(on-key ch)
|
|
(return-from on-key nil))
|
|
;; Enter
|
|
((or (eq ch :enter) (eql ch 13) (eql ch 10)
|
|
(eql ch #\Newline) (eql ch #\Return))
|
|
;; Multi-line: if buffer ends with \, strip it and insert newline
|
|
(if (and (st :input-buffer) (eql (first (st :input-buffer)) #\\))
|
|
(progn (pop (st :input-buffer))
|
|
(push #\Newline (st :input-buffer))
|
|
(setf (st :dirty) (list nil nil t)))
|
|
(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
|
|
;; v0.7.2: HITL inline — structured approval/denial
|
|
((and (>= (length text) 9)
|
|
(string-equal (subseq text 0 9) "/approve "))
|
|
(let ((token (string-trim '(#\Space) (subseq text 9))))
|
|
(send-daemon (list :type :event :payload
|
|
(list :action :hitl-respond :token token :decision :approved)))
|
|
(add-msg :system (format nil "Approved: ~a" token))))
|
|
((and (>= (length text) 6)
|
|
(string-equal (subseq text 0 6) "/deny "))
|
|
(let ((token (string-trim '(#\Space) (subseq text 6))))
|
|
(send-daemon (list :type :event :payload
|
|
(list :action :hitl-respond :token token :decision :denied)))
|
|
(add-msg :system (format nil "Denied: ~a" token))))
|
|
;; /help command
|
|
((string-equal text "/help")
|
|
(add-msg :system
|
|
"/eval <expr> Evaluate Lisp expression")
|
|
(add-msg :system
|
|
"/focus <proj> Set project context")
|
|
(add-msg :system
|
|
"/scope <s> Change scope (memex/session/project)")
|
|
(add-msg :system
|
|
"/unfocus Pop context stack")
|
|
(add-msg :system
|
|
"/theme Show current color theme")
|
|
(add-msg :system
|
|
"/help Show this help")
|
|
(add-msg :system
|
|
"\\ + Enter Multi-line input"))
|
|
;; /theme command
|
|
((string-equal text "/theme")
|
|
(add-msg :system (format nil "Theme: ~a — user=~a agent=~a system=~a input=~a"
|
|
*tui-theme-current-name*
|
|
(getf *tui-theme* :user)
|
|
(getf *tui-theme* :agent)
|
|
(getf *tui-theme* :system)
|
|
(getf *tui-theme* :input)))
|
|
(add-msg :system "Presets: /theme dark | light | solarized | gruvbox"))
|
|
((and (>= (length text) 7)
|
|
(string-equal (subseq text 0 7) "/theme "))
|
|
(let ((name (string-trim '(#\Space) (subseq text 7))))
|
|
(if (theme-switch name)
|
|
(add-msg :system (format nil "Theme switched to ~a" name))
|
|
(add-msg :system (format nil "Unknown theme '~a'. Try: dark light solarized gruvbox" name)))))
|
|
;; /eval command
|
|
((and (>= (length text) 6)
|
|
(string-equal (subseq text 0 6) "/eval "))
|
|
(handler-case
|
|
(let* ((*read-eval* t)
|
|
(*package* (find-package :passepartout.channel-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)))))
|
|
;; /focus <project> — set project context
|
|
((and (>= (length text) 7)
|
|
(string-equal (subseq text 0 7) "/focus "))
|
|
(let ((project (string-trim '(#\Space) (subseq text 7))))
|
|
(if (and (fboundp 'focus-project) (> (length project) 0))
|
|
(progn (funcall 'focus-project project nil)
|
|
(add-msg :system (format nil "Focused on project: ~a" project)))
|
|
(add-msg :system "Usage: /focus <project-name>"))))
|
|
;; /scope <scope> — change context scope
|
|
((and (>= (length text) 7)
|
|
(string-equal (subseq text 0 7) "/scope "))
|
|
(let ((scope-str (string-trim '(#\Space) (subseq text 7))))
|
|
(cond
|
|
((and (fboundp 'focus-session) (string-equal scope-str "session"))
|
|
(funcall 'focus-session)
|
|
(add-msg :system "Scope: session"))
|
|
((and (fboundp 'focus-project) (string-equal scope-str "project"))
|
|
(funcall 'focus-project nil nil)
|
|
(add-msg :system "Scope: project"))
|
|
((and (fboundp 'focus-memex) (string-equal scope-str "memex"))
|
|
(funcall 'focus-memex)
|
|
(add-msg :system "Scope: memex"))
|
|
(t (add-msg :system "Usage: /scope memex|session|project")))))
|
|
;; /unfocus — pop context
|
|
((and (>= (length text) 8)
|
|
(string-equal (subseq text 0 8) "/unfocus"))
|
|
(if (fboundp 'unfocus)
|
|
(progn (funcall 'unfocus)
|
|
(add-msg :system "Popped context"))
|
|
(add-msg :system "Context manager not loaded")))
|
|
;; /quit — save history and exit
|
|
((or (string-equal text "/quit") (string-equal text "/q"))
|
|
(let ((hist-file (merge-pathnames ".cache/passepartout/history"
|
|
(user-homedir-pathname))))
|
|
(uiop:ensure-all-directories-exist (list hist-file))
|
|
(with-open-file (out hist-file :direction :output
|
|
:if-exists :supersede :if-does-not-exist :create)
|
|
(dolist (entry (reverse (st :input-history)))
|
|
(write-line entry out))))
|
|
(add-msg :system "* Goodbye *")
|
|
(send-daemon (list :type :event :payload '(:action :quit)))
|
|
(setf (st :running) nil))
|
|
;; /reconnect — re-establish daemon connection
|
|
((string-equal text "/reconnect")
|
|
(disconnect-daemon)
|
|
(connect-daemon))
|
|
;; Normal message
|
|
(t
|
|
(add-msg :user text)
|
|
(setf (st :busy) t)
|
|
(send-daemon (list :type :event
|
|
:payload (list :sensor :user-input :text text)))))
|
|
(setf (st :input-buffer) nil)
|
|
(setf (st :cursor-pos) 0)
|
|
(setf (st :dirty) (list t t t))))))
|
|
;; Tab — command completion (v0.7.0: extended with subcommand + file paths)
|
|
((or (eql ch 9) (eq ch :tab))
|
|
(let ((text (input-string)))
|
|
(cond
|
|
;; @ prefix — file path completion
|
|
((and (>= (length text) 1) (eql (char text 0) #\@))
|
|
(let* ((partial (subseq text 1))
|
|
(memex (or (uiop:getenv "MEMEX_DIR")
|
|
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
|
|
(proj (merge-pathnames (make-pathname :directory '(:relative "projects")) memex))
|
|
(files (handler-case (append (uiop:directory-files proj "**/*.org")
|
|
(uiop:directory-files proj "**/*.lisp"))
|
|
(error () nil)))
|
|
(names (mapcar (lambda (f) (subseq (namestring f) (1+ (length (namestring proj))))) files))
|
|
(match (find-if (lambda (n) (and (>= (length n) (length partial))
|
|
(string-equal n partial :end2 (length partial))))
|
|
names)))
|
|
(when match
|
|
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "@" match) 'list)))
|
|
(setf (st :dirty) (list nil nil t)))))
|
|
;; /theme subcommand
|
|
((and (>= (length text) 7) (string-equal (subseq text 0 7) "/theme "))
|
|
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
|
|
(names '("dark" "light" "solarized" "gruvbox"))
|
|
(match (if (string= partial "") (first names)
|
|
(find partial names :test #'string-equal))))
|
|
(when match
|
|
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list)))
|
|
(setf (st :dirty) (list nil nil t)))))
|
|
;; /focus subcommand
|
|
((and (>= (length text) 7) (string-equal (subseq text 0 7) "/focus "))
|
|
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
|
|
(memex (or (uiop:getenv "MEMEX_DIR")
|
|
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
|
|
(proj (merge-pathnames (make-pathname :directory '(:relative "projects")) memex))
|
|
(dirs (handler-case (mapcar (lambda (d) (car (last (pathname-directory d))))
|
|
(uiop:subdirectories proj))
|
|
(error () nil)))
|
|
(match (if (string= partial "") (first dirs)
|
|
(find-if (lambda (d) (and (>= (length d) (length partial))
|
|
(string-equal d partial :end2 (length partial))))
|
|
dirs))))
|
|
(when match
|
|
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/focus " match) 'list)))
|
|
(setf (st :dirty) (list nil nil t)))))
|
|
;; Command prefix /
|
|
((and (> (length text) 1) (eql (char text 0) #\/))
|
|
(let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit"))
|
|
(match (find text cmds :test
|
|
(lambda (in cmd) (and (>= (length cmd) (length in))
|
|
(string-equal cmd in :end1 (length in)))))))
|
|
(when match
|
|
(setf (st :input-buffer) (reverse (coerce match 'list)))
|
|
(when (member match '("/eval" "/focus" "/scope") :test #'string=)
|
|
(push #\Space (st :input-buffer)))
|
|
(setf (st :dirty) (list nil nil t))))))))
|
|
;; Backspace
|
|
((or (eq ch :backspace) (eql ch 127) (eql ch 8)
|
|
(eql ch #\Backspace))
|
|
(input-delete-char)
|
|
(setf (st :dirty) (list nil nil t)))
|
|
;; Left arrow
|
|
((or (eq ch :left) (eql ch 260))
|
|
(when (> (or (st :cursor-pos) 0) 0)
|
|
(decf (st :cursor-pos))
|
|
(setf (st :dirty) (list nil nil t))))
|
|
;; Right arrow
|
|
((or (eq ch :right) (eql ch 261))
|
|
(when (< (or (st :cursor-pos) 0) (length (st :input-buffer)))
|
|
(incf (st :cursor-pos))
|
|
(setf (st :dirty) (list nil nil t))))
|
|
;; Up arrow
|
|
((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)))))
|
|
;; Down arrow
|
|
((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)))))
|
|
;; PageUp
|
|
((or (eq ch :ppage) (eql ch 339))
|
|
(let ((max-offset (max 0 (- (length (st :messages)) 1))))
|
|
(setf (st :scroll-offset) (min max-offset (+ (st :scroll-offset) 5))))
|
|
(setf (st :dirty) (list nil t nil)))
|
|
;; PageDown
|
|
((or (eq ch :npage) (eql ch 338))
|
|
(setf (st :scroll-offset) (max 0 (- (st :scroll-offset) 5)))
|
|
(setf (st :dirty) (list nil t nil)))
|
|
;; Printable
|
|
(t
|
|
(let ((chr (typecase ch
|
|
(character ch)
|
|
(integer (code-char ch))
|
|
(t nil))))
|
|
(when (and chr (graphic-char-p chr))
|
|
(input-insert-char chr)
|
|
(setf (st :dirty) (list nil nil t))))))))
|
|
|
|
(defun on-daemon-msg (msg)
|
|
(let* ((payload (getf msg :payload))
|
|
(text (getf payload :text))
|
|
(msg-type (getf msg :type))
|
|
(action (getf payload :action))
|
|
(gate-trace (getf msg :gate-trace))
|
|
(rule-count (getf payload :rule-count))
|
|
(foveal-id (getf payload :foveal-id)))
|
|
;; v0.7.1: streaming chunk
|
|
(when (eq msg-type :stream-chunk)
|
|
(cond
|
|
((string= text "")
|
|
;; Final chunk: stamp time, clear streaming
|
|
(when (> (length (st :messages)) 0)
|
|
(let ((idx (1- (length (st :messages)))))
|
|
(setf (getf (aref (st :messages) idx) :streaming) nil)
|
|
(setf (getf (aref (st :messages) idx) :time) (now))))
|
|
(setf (st :streaming-text) nil)
|
|
(setf (st :busy) nil)
|
|
(setf (st :dirty) (list nil t nil))
|
|
(return-from on-daemon-msg nil))
|
|
((null (st :streaming-text))
|
|
;; First chunk: add new streaming message
|
|
(setf (st :streaming-text) "")
|
|
(setf (st :busy) nil)
|
|
(add-msg :agent text)
|
|
(let ((idx (1- (length (st :messages)))))
|
|
(setf (getf (aref (st :messages) idx) :streaming) t))
|
|
(setf (st :streaming-text) text)
|
|
(setf (st :dirty) (list nil t nil))
|
|
(return-from on-daemon-msg nil))
|
|
(t
|
|
;; Subsequent chunk: append
|
|
(let* ((new-text (concatenate 'string (st :streaming-text) text))
|
|
(idx (1- (length (st :messages)))))
|
|
(setf (st :streaming-text) new-text)
|
|
(setf (getf (aref (st :messages) idx) :content) new-text)
|
|
(setf (st :dirty) (list nil t nil)))
|
|
(return-from on-daemon-msg nil))))
|
|
(when rule-count (setf (st :rule-count) rule-count))
|
|
(when foveal-id (setf (st :foveal-id) foveal-id))
|
|
(cond
|
|
(text (setf (st :busy) nil)
|
|
(add-msg :agent text :gate-trace gate-trace))
|
|
((eq action :handshake)
|
|
(add-msg :system (format nil "Connected v~a" (getf payload :version))))
|
|
(t (add-msg :agent (format nil "~a" msg))))))
|
|
|
|
(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 () nil)))))
|
|
|
|
(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 () nil)))
|
|
|
|
(defun reader-loop (s)
|
|
(let ((consecutive-nils 0))
|
|
(loop while (and (st :running) (open-stream-p s))
|
|
do (let ((msg (recv-daemon s)))
|
|
(if msg
|
|
(progn (queue-event (list :type :daemon :payload msg))
|
|
(setf consecutive-nils 0))
|
|
(progn (sleep 0.5)
|
|
(incf consecutive-nils)
|
|
(when (> consecutive-nils 10)
|
|
(queue-event (list :type :disconnected))
|
|
(return))))))))
|
|
|
|
(defun load-history ()
|
|
"Load input history from disk on TUI startup."
|
|
(let ((hist-file (merge-pathnames ".cache/passepartout/history"
|
|
(user-homedir-pathname))))
|
|
(when (uiop:file-exists-p hist-file)
|
|
(with-open-file (in hist-file :direction :input)
|
|
(loop for line = (read-line in nil nil)
|
|
while line
|
|
do (push line (st :input-history))))
|
|
(setf (st :input-history) (nreverse (st :input-history))))))
|
|
|
|
(defun connect-daemon (&optional (host "127.0.0.1") (port 9105))
|
|
(add-msg :system "* Connecting to daemon... *")
|
|
(loop for attempt from 1 to 3
|
|
for backoff = 0 then 3
|
|
do (sleep backoff)
|
|
(handler-case
|
|
(let ((s (usocket:socket-connect host port :timeout 5)))
|
|
(setf (st :stream) (usocket:socket-stream s)
|
|
(st :connected) t)
|
|
(bt:make-thread (lambda () (reader-loop (st :stream)))
|
|
:name "tui-reader")
|
|
(add-msg :system (format nil "* Connected v~a *" "0.5.0"))
|
|
(return-from connect-daemon t))
|
|
(usocket:connection-refused-error (c)
|
|
(when (= attempt 3)
|
|
(add-msg :system (format nil "* No daemon on port ~a after ~a attempts *"
|
|
port attempt))))
|
|
(error (c)
|
|
(add-msg :system (format nil "* Connection attempt ~a failed: ~a *"
|
|
attempt c))
|
|
(when (= attempt 3)
|
|
(add-msg :system "* TIP: run 'passepartout daemon' first *")))))
|
|
nil)
|
|
|
|
(defun disconnect-daemon ()
|
|
(when (st :stream)
|
|
(ignore-errors (close (st :stream)))
|
|
(setf (st :stream) nil (st :connected) nil)
|
|
(add-msg :system "* Disconnected *")))
|
|
|
|
(defun tui-main ()
|
|
(init-state)
|
|
(load-history)
|
|
(theme-load)
|
|
(with-screen (scr :input-blocking nil :input-echoing nil :cursor-visible nil)
|
|
(let* ((h (or (height scr) 24))
|
|
(w (or (width scr) 80))
|
|
(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
|
|
(input-blocking iw) nil
|
|
(st :dirty) (list t t t)
|
|
;; Store windows in state for SIGWINCH handler
|
|
(st :scr) scr (st :sw) sw (st :cw) cw (st :iw) iw)
|
|
(connect-daemon)
|
|
(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 ()
|
|
(add-msg :system "* Swank unavailable *"))))
|
|
;; Initial render before the main loop — otherwise the screen stays
|
|
;; blank until the first keystroke (get-char blocks).
|
|
(redraw sw cw ch iw)
|
|
(refresh scr)
|
|
(loop while (st :running) do
|
|
(dolist (ev (drain-queue))
|
|
(cond
|
|
((eq (getf ev :type) :daemon)
|
|
(on-daemon-msg (getf ev :payload)))
|
|
((eq (getf ev :type) :disconnected)
|
|
(setf (st :connected) nil
|
|
(st :busy) nil)
|
|
(add-msg :system "* Connection lost — type /reconnect to retry *"))))
|
|
(let ((ch (get-char iw)))
|
|
(cond
|
|
((or (not ch) (equal ch -1)) nil)
|
|
;; KEY_RESIZE — terminal was resized (SIGWINCH from ncurses)
|
|
((eql ch 410)
|
|
(let* ((new-h (or (height scr) 24))
|
|
(new-w (or (width scr) 80))
|
|
(new-ch (- new-h 5)))
|
|
(setq sw (make-instance 'window :height 3 :width (- new-w 2) :y 0 :x 1)
|
|
ch new-ch
|
|
cw (make-instance 'window :height new-ch :width (- new-w 2) :y 3 :x 1)
|
|
iw (make-instance 'window :height 1 :width (- new-w 2) :y (- new-h 1) :x 1)
|
|
w new-w
|
|
h new-h)
|
|
(setf (function-keys-enabled-p iw) t
|
|
(input-blocking iw) nil
|
|
(st :dirty) (list t t t)
|
|
(st :sw) sw (st :cw) cw (st :iw) iw)
|
|
(redraw sw cw ch iw)
|
|
(refresh scr)))
|
|
(t (on-key ch))))
|
|
(redraw sw cw ch iw)
|
|
(refresh scr)
|
|
(sleep 0.03))
|
|
(disconnect-daemon))))
|
|
|
|
(eval-when (:compile-toplevel :load-toplevel :execute)
|
|
(ql:quickload :fiveam :silent t))
|
|
|
|
(defpackage :passepartout-tui-tests
|
|
(:use :cl :passepartout :passepartout.channel-tui)
|
|
(:export #:tui-suite))
|
|
|
|
(in-package :passepartout-tui-tests)
|
|
|
|
(fiveam:def-suite tui-suite :description "Verification of the TUI model and event handling")
|
|
(fiveam:in-suite tui-suite)
|
|
|
|
(fiveam:test test-init-state
|
|
"Contract model.1: init-state returns fresh state plist with required keys."
|
|
(init-state)
|
|
(fiveam:is (eq t (st :running)))
|
|
(fiveam:is (eq :chat (st :mode)))
|
|
(fiveam:is (eq nil (st :connected)))
|
|
(fiveam:is (eq nil (st :stream)))
|
|
(fiveam:is (zerop (length (st :messages))))
|
|
(fiveam:is (eq 0 (st :scroll-offset)))
|
|
(fiveam:is (eq nil (st :busy))))
|
|
|
|
(fiveam:test test-add-msg
|
|
"Contract model.2: add-msg appends a message with role, content, and time."
|
|
(init-state)
|
|
(add-msg :user "hello")
|
|
(let* ((msgs (st :messages))
|
|
(msg (aref msgs 0)))
|
|
(fiveam:is (eq :user (getf msg :role)))
|
|
(fiveam:is (string= "hello" (getf msg :content)))
|
|
(fiveam:is (stringp (getf msg :time)))
|
|
(fiveam:is (= 5 (length (getf msg :time))))))
|
|
|
|
(fiveam:test test-add-msg-dirty-flag
|
|
"Contract model.2: add-msg sets dirty flags for status and chat."
|
|
(init-state)
|
|
(setf (st :dirty) (list nil nil nil))
|
|
(add-msg :system "boot")
|
|
(let ((dirty (st :dirty)))
|
|
(fiveam:is (eq t (first dirty)))
|
|
(fiveam:is (eq t (second dirty)))
|
|
(fiveam:is (eq nil (third dirty)))))
|
|
|
|
(fiveam:test test-queue-event-roundtrip
|
|
"Contract model.3: queue-event + drain-queue preserves events in order."
|
|
(init-state)
|
|
(queue-event '(:type :key :payload (:ch 13)))
|
|
(queue-event '(:type :daemon :payload (:text "hi")))
|
|
(let ((evs (drain-queue)))
|
|
(fiveam:is (= 2 (length evs)))
|
|
(fiveam:is (equal '(:type :key :payload (:ch 13)) (first evs)))
|
|
(fiveam:is (equal '(:type :daemon :payload (:text "hi")) (second evs)))
|
|
(fiveam:is (null (drain-queue)))))
|
|
|
|
(fiveam:test test-on-key-enter-sends-user-message
|
|
"Contract 1: on-key with Enter extracts input, adds user message, clears buffer."
|
|
(init-state)
|
|
;; Simulate typing "test"
|
|
(dolist (ch '(#\t #\e #\s #\t))
|
|
(on-key (char-code ch)))
|
|
(fiveam:is (string= "test" (input-string)))
|
|
;; Simulate Enter key — ncurses returns 343 (KEY_ENTER) when keypad is enabled
|
|
(on-key 343)
|
|
;; Input buffer should be cleared
|
|
(fiveam:is (string= "" (input-string)))
|
|
;; A user message should be in the message list
|
|
(let ((msgs (st :messages)))
|
|
(fiveam:is (>= (length msgs) 1))
|
|
(let ((last (aref msgs 0)))
|
|
(fiveam:is (eq :user (getf last :role)))
|
|
(fiveam:is (string= "test" (getf last :content))))))
|
|
|
|
(fiveam:test test-on-key-eval-command
|
|
"Contract 1: on-key handles /eval command and displays result."
|
|
(init-state)
|
|
;; Type "/eval (+ 1 2)"
|
|
(dolist (ch (coerce "/eval (+ 1 2)" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 343)
|
|
(let ((msgs (st :messages)))
|
|
(fiveam:is (>= (length msgs) 1))
|
|
(let ((last-msg (aref msgs 0)))
|
|
(fiveam:is (eq :system (getf last-msg :role)))
|
|
(fiveam:is (search "=> 3" (getf last-msg :content))))))
|
|
|
|
(fiveam:test test-on-key-backspace
|
|
"Contract 1: on-key with Backspace removes last character from buffer."
|
|
(init-state)
|
|
(dolist (ch '(#\a #\b #\c))
|
|
(on-key (char-code ch)))
|
|
(fiveam:is (string= "abc" (input-string)))
|
|
;; ncurses returns 263 (KEY_BACKSPACE) when keypad is enabled
|
|
(on-key 263)
|
|
(fiveam:is (string= "ab" (input-string))))
|
|
|
|
(fiveam:test test-on-key-focus-command
|
|
"Contract 1: /focus command parses project name."
|
|
(init-state)
|
|
(dolist (ch (coerce "/focus myapp" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 343)
|
|
(let ((msg (aref (st :messages) 0)))
|
|
(fiveam:is (eq :system (getf msg :role)))))
|
|
|
|
(fiveam:test test-on-key-scope-command
|
|
"Contract 1: /scope command with valid argument."
|
|
(init-state)
|
|
(dolist (ch (coerce "/scope memex" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 343)
|
|
(let ((msg (aref (st :messages) 0)))
|
|
(fiveam:is (eq :system (getf msg :role)))))
|
|
|
|
(fiveam:test test-on-key-unfocus-command
|
|
"Contract 1: /unfocus command dispatches correctly."
|
|
(init-state)
|
|
(dolist (ch (coerce "/unfocus" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 343)
|
|
(let ((msg (aref (st :messages) 0)))
|
|
(fiveam:is (eq :system (getf msg :role)))))
|
|
|
|
(fiveam:test test-on-key-tab-completion
|
|
"Contract 1: Tab completes / commands when input starts with /."
|
|
(init-state)
|
|
(dolist (ch (coerce "/ev" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 9)
|
|
(fiveam:is (string= "/eval " (input-string))))
|
|
|
|
(fiveam:test test-on-key-tab-no-slash
|
|
"Contract 1: Tab does nothing when input doesn't start with /."
|
|
(init-state)
|
|
(dolist (ch (coerce "hello" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 9)
|
|
(fiveam:is (string= "hello" (input-string))))
|
|
|
|
(fiveam:test test-on-key-multiline
|
|
"Contract 1: \\ + Enter inserts newline instead of sending."
|
|
(init-state)
|
|
(dolist (ch (coerce "line1" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key (char-code #\\))
|
|
(on-key 343)
|
|
(fiveam:is (search "line1" (input-string)))
|
|
(fiveam:is (search (string #\Newline) (input-string))))
|
|
|
|
(fiveam:test test-on-key-help
|
|
"Contract 1: /help displays command list."
|
|
(init-state)
|
|
(dolist (ch (coerce "/help" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 343)
|
|
(let ((msgs (st :messages)))
|
|
(fiveam:is (>= (length msgs) 3))
|
|
(fiveam:is (some (lambda (m) (search "/eval" (getf m :content))) msgs))))
|
|
|
|
(fiveam:test test-activity-indicator
|
|
"Contract model: :busy flag is set on send and cleared on agent response."
|
|
(init-state)
|
|
(fiveam:is (eq nil (st :busy)))
|
|
;; Simulate sending a normal message (sets busy)
|
|
(dolist (ch (coerce "hello" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 343)
|
|
(fiveam:is (eq t (st :busy)))
|
|
;; Simulate receiving an agent response (clears busy)
|
|
(on-daemon-msg '(:type :event :payload (:text "hi back")))
|
|
(fiveam:is (eq nil (st :busy))))
|
|
|
|
(fiveam:test test-theme
|
|
"Contract view: *tui-theme* provides color mappings."
|
|
(fiveam:is (eq :green (getf *tui-theme* :user)))
|
|
(fiveam:is (eq :white (getf *tui-theme* :agent)))
|
|
(fiveam:is (eq :yellow (getf *tui-theme* :system)))
|
|
(fiveam:is (eq :cyan (getf *tui-theme* :input)))
|
|
(fiveam:is (eq :white (theme-color :unknown-role))))
|
|
|
|
(fiveam:test test-on-key-ctrl-u-clears
|
|
"Contract 1/v0.7.0: Ctrl+U clears the input buffer."
|
|
(init-state)
|
|
(dolist (ch '(#\h #\i)) (on-key (char-code ch)))
|
|
(on-key 21) ; Ctrl+U
|
|
(fiveam:is (string= "" (input-string))))
|
|
|
|
(fiveam:test test-on-key-ctrl-l-redraws
|
|
"Contract 1/v0.7.0: Ctrl+L sets all dirty flags."
|
|
(init-state)
|
|
(setf (st :dirty) (list nil nil nil))
|
|
(on-key 12) ; Ctrl+L
|
|
(let ((d (st :dirty)))
|
|
(fiveam:is (eq t (first d)))
|
|
(fiveam:is (eq t (second d)))))
|
|
|
|
(fiveam:test test-scroll-notify
|
|
"Contract/v0.7.0: add-msg sets scroll-notify when scrolled up."
|
|
(init-state)
|
|
(setf (st :scroll-at-bottom) nil)
|
|
(add-msg :agent "hi")
|
|
(fiveam:is (eq t (st :scroll-notify)))
|
|
(setf (st :scroll-at-bottom) t (st :scroll-notify) nil)
|
|
(add-msg :agent "hi2")
|
|
(fiveam:is (eq nil (st :scroll-notify))))
|
|
|
|
(fiveam:test test-tab-subcommand
|
|
"Contract/v0.7.0: Tab completes subcommand for /theme."
|
|
(init-state)
|
|
(dolist (ch (coerce "/theme " 'list)) (on-key (char-code ch)))
|
|
(on-key 9)
|
|
(fiveam:is (search "dark" (input-string) :test #'char-equal)))
|
|
|
|
;; ── v0.7.1 Streaming ──
|
|
|
|
(fiveam:test test-stream-chunk-appends
|
|
"Contract/v0.7.1: stream-chunk frame appends to last message."
|
|
(init-state)
|
|
(on-daemon-msg '(:type :stream-chunk :payload (:text "Hello")))
|
|
(on-daemon-msg '(:type :stream-chunk :payload (:text " world")))
|
|
(let ((msgs (st :messages)))
|
|
(fiveam:is (= 1 (length msgs)))
|
|
(let ((msg (aref msgs 0)))
|
|
(fiveam:is (eq :agent (getf msg :role)))
|
|
(fiveam:is (string= "Hello world" (getf msg :content)))
|
|
(fiveam:is (eq t (getf msg :streaming))))))
|
|
|
|
(fiveam:test test-stream-chunk-final
|
|
"Contract/v0.7.1: final empty chunk stamps timestamp and clears streaming flag."
|
|
(init-state)
|
|
(on-daemon-msg '(:type :stream-chunk :payload (:text "Hi")))
|
|
(on-daemon-msg '(:type :stream-chunk :payload (:text "")))
|
|
(let ((msg (aref (st :messages) 0)))
|
|
(fiveam:is (stringp (getf msg :time)))
|
|
(fiveam:is (string= "Hi" (getf msg :content)))
|
|
(fiveam:is (null (st :streaming-text)))))
|
|
|
|
(fiveam:test test-stream-interrupt
|
|
"Contract/v0.7.1: Esc during streaming appends [interrupted] and finalizes."
|
|
(init-state)
|
|
(on-daemon-msg '(:type :stream-chunk :payload (:text "partial")))
|
|
(on-key 27)
|
|
(let ((msg (aref (st :messages) 0)))
|
|
(fiveam:is (stringp (getf msg :time)))
|
|
(fiveam:is (search "[interrupted]" (getf msg :content)))
|
|
(fiveam:is (null (st :streaming-text)))
|
|
(fiveam:is (null (st :busy)))))
|
|
|
|
(fiveam:test test-stream-check-skip
|
|
"Contract/v0.7.1: Esc without active streaming does nothing."
|
|
(init-state)
|
|
(on-key 27)
|
|
(fiveam:is (null (st :streaming-text)))
|
|
(fiveam:is (= 0 (length (st :messages)))))
|
|
|
|
(fiveam:test test-tab-open-url
|
|
"Contract/v0.7.1: Tab on empty input with URL message extracts URL."
|
|
(init-state)
|
|
(add-msg :agent "visit https://example.com for info")
|
|
(on-key 9)
|
|
(fiveam:is (string= "https://example.com" (st :url-buffer))))
|
|
|
|
;; ── v0.7.2 HITL ──
|
|
|
|
(fiveam:test test-hitl-approve-parsed
|
|
"Contract v0.7.2: /approve HITL-xxxx sends structured event, not raw text."
|
|
(init-state)
|
|
(dolist (ch (coerce "/approve HITL-abcd" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 343)
|
|
;; Should add a system message confirming approval, not a user message
|
|
(let ((msgs (st :messages)))
|
|
(fiveam:is (>= (length msgs) 1))
|
|
(let ((m (aref msgs 0)))
|
|
(fiveam:is (eq :system (getf m :role)))
|
|
(fiveam:is (search "Approved" (getf m :content))))))
|
|
|
|
(fiveam:test test-hitl-deny-parsed
|
|
"Contract v0.7.2: /deny HITL-xxxx sends structured denial."
|
|
(init-state)
|
|
(dolist (ch (coerce "/deny HITL-xyz" 'list))
|
|
(on-key (char-code ch)))
|
|
(on-key 343)
|
|
(let ((m (aref (st :messages) 0)))
|
|
(fiveam:is (eq :system (getf m :role)))
|
|
(fiveam:is (search "Denied" (getf m :content)))))
|