Files
passepartout/org/channel-tui-main.org
Amr Gharbeia e763768122 migrate on-key to text-input callbacks
Replace the 400-line on-key function with cl-tty text-input callbacks.
Add on-cancel, on-tab, on-history slots to cl-tty's text-input widget.
Remove defkeymap :local up/down/escape handlers.
Remove (member k '(:enter :tab :escape :up :down)) from process-key-event.
PageUp/PageDown stay in keymap, routed to handle-ppage/handle-npage.
Fix XDG cl-tty.asd to remove stale select-package/select references
and add missing markdown-package/markdown entries.
Fix #\) character literal (not valid in all contexts).
Fix several missing closing parentheses in handle-tab and command-dispatch.
2026-05-20 13:36:21 -04:00

56 KiB

Passepartout TUI — Controller

Controller

Event handlers + daemon I/O + main loop.

Contract

  1. (on-key ch): dispatches key presses: Enter triggers send (extracts input buffer, pushes history, sends to daemon, clears buffer), \\ + Enter inserts a literal newline (multi-line input), /help lists all commands, /eval <expr> evaluates a Lisp expression, /focus <proj> switches project context, /scope <scope> changes context scope, /unfocus pops context, Tab completes command names, Backspace deletes, arrows scroll chat and history. v0.7.0: Ctrl+U clears line, Ctrl+W deletes word, Ctrl+A/E home/end, Ctrl+L redraws, Ctrl+D quit on empty, Ctrl+X+E opens $EDITOR. Non-printable keys are ignored.
  2. (on-daemon-msg msg): processes inbound daemon messages. Routes text responses to chat display (:agent), handshake to system messages, routes errors to log via log-message. Extracts :gate-trace (attached to message), :rule-count, and :foveal-id (v0.4.0 differentiator) from daemon response and updates TUI state for status bar rendering.
  3. (send-daemon msg): serializes and sends a message to the daemon over the framed TCP protocol.
  4. (tui-main): the main loop — connects to daemon, initializes Croatoan windows, optionally starts Swank REPL, runs render/input event loop at ~30fps.

Event Handlers

(in-package :passepartout.channel-tui)

(defun input-text ()
  "Get current input text from the text-input widget."
  (cl-tty.input:text-input-value (st :text-input)))

(defun (setf input-text) (value)
  "Set current input text and reset cursor."
  (setf (cl-tty.input:text-input-value (st :text-input)) value
        (cl-tty.input:text-input-cursor (st :text-input)) (length value)))

(defun handle-submit (text)
  "Called when user presses Enter in the text-input widget."
  (let ((trimmed (string-trim '(#\Space #\Tab) text)))
    (when (> (length trimmed) 0)
      (push trimmed (st :input-history))
      (setf (st :input-hpos) 0 (st :scroll-offset) 0)
      (command-dispatch trimmed)
      ;; Clear input text — replace with a fresh widget with same callbacks
      (setf (st :text-input) (make-text-input-with-callbacks))
      (setf (st :dirty) (list t t t)))))

(defun handle-cancel ()
  "Called when user presses Escape in the text-input widget."
  (cond
    ((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
               (getf (aref (st :messages) idx) :time) (now))))
     (setf (st :streaming-text) nil (st :busy) nil)
     (setf (st :dirty) (list t t nil)))
    ((st :search-mode)
     (setf (st :search-mode) nil (st :search-matches) nil (st :search-query) "")
     (setf (st :dirty) (list nil t nil))
     (add-msg :system "Search exited"))))

(defun handle-tab (text pos)
  "Called when user presses Tab in the text-input widget.
Returns two values: new-text and new-cursor-pos (or nil if no completion)."
  (declare (ignore pos))
  (cond
    ;; URL extraction on empty input
    ((string= "" text)
     (if (st :url-buffer)
         (progn (add-msg :system (format nil "Opening ~a" (st :url-buffer)))
                (setf (st :url-buffer) nil)
                nil)
         (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)))))
           (when url
             (setf (st :url-buffer) url)
             (add-msg :system (format nil "Press Tab to open ~a" url))
             (setf (st :dirty) (list t t nil)))
           nil)))
    ;; @ 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
         (values (concatenate 'string "@" match) (length (concatenate 'string "@" match))))))
    ;; /theme subcommand
    ((and (>= (length text) 7) (string-equal (subseq text 0 7) "/theme "))
     (let* ((partial (string-trim '(#\Space) (subseq text 7)))
            (names '("amber" "gold" "terracotta" "sepia" "nord-warm" "monokai-warm"
                      "gruvbox-warm" "light-amber" "catppuccin" "tokyonight" "dracula"
                      "gemini" "mono"))
            (match (if (string= partial "") (first names)
                       (find partial names :test #'string-equal))))
       (when match (values (concatenate 'string "/theme " match)))))
    ;; /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 (values (concatenate 'string "/focus " match)))))
    ;; 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
         (if (member match '("/eval" "/focus" "/scope") :test #'string=)
             (values (concatenate 'string match " "))
             (values match)))))
    (t nil))))


(defun handle-history (direction)
  "Called when user presses Up/Down in the text-input widget.
Returns two values: new-text and new-cursor-pos (or nil if no movement)."
  (let ((h (st :input-history)) (p (st :input-hpos)))
    (if (eq direction :up)
        (when (and h (< p (1- (length h))))
          (incf (st :input-hpos))
          (values (nth (st :input-hpos) h) (length (nth (st :input-hpos) h))))
        (when (> (st :input-hpos) 0)
          (decf (st :input-hpos))
          (if (and h (< (st :input-hpos) (length h)))
              (values (nth (st :input-hpos) h) (length (nth (st :input-hpos) h)))
              (values "" 0))))))

(defun handle-ppage ()
  "Scroll chat up by one page."
  (let ((max-offset (max 0 (- (length (st :messages)) 1))))
    (setf (st :scroll-offset) (min max-offset (+ (st :scroll-offset) 10))))
  (setf (st :dirty) (list nil t nil)))

(defun handle-npage ()
  "Scroll chat down by one page."
  (setf (st :scroll-offset) (max 0 (- (st :scroll-offset) 10)))
  (setf (st :dirty) (list nil t nil)))

(defun handle-search-navigate (direction)
  "Search mode: move to prev/next match."
  (let* ((matches (st :search-matches))
         (idx (st :search-match-idx))
         (new-idx (if (eq direction :up)
                      (max 0 (1- idx))
                      (min (1- (length matches)) (1+ idx)))))
    (setf (st :search-match-idx) new-idx)
    (when matches
      (setf (st :scroll-offset) (nth new-idx matches))
      (add-msg :system (format nil "Match ~d/~d" (1+ new-idx) (length matches)))
      (setf (st :dirty) (list nil t nil)))))

(defun command-dispatch (text)
  "Handle a submitted command or message. TEXT is the trimmed input.
Called from handle-submit."
  (cond
    ((string-equal text "/undo")
     (send-daemon (list :type :event :payload (list :sensor :undo)))
     (add-msg :system "Undo: restoring memory to previous state"))
    ((string-equal text "/redo")
     (send-daemon (list :type :event :payload (list :sensor :redo)))
     (add-msg :system "Redo: restoring memory"))
    ((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))
       (resolve-hitl-panel :approved)))
    ((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))
       (resolve-hitl-panel :denied)))
    ((string-equal text "/why")
     (let ((msgs (st :messages)) (found nil))
       (loop for i from (1- (length msgs)) downto 0
             for m = (aref msgs i) for gt = (getf m :gate-trace)
             when (and gt (listp gt) (> (length gt) 0))
             do (setf found t)
                (dolist (entry gt)
                  (let* ((gate (getf entry :gate)) (result (getf entry :result))
                         (reason (getf entry :reason))
                         (prefix (case result (:passed "✓") (:blocked "✗") (:approval "→") (t "?"))))
                    (add-msg :system (format nil "  ~a ~a~@[: ~a~]" prefix gate reason)))))
       (unless found (add-msg :system "No gate trace on last agent message."))))
    ((> (length text) 8)
     (when (string-equal (subseq text 0 8) "/search ")
       (let ((query (string-trim '(#\Space) (subseq text 8))))
         (when (> (length query) 0)
           (let (matches)
             (dotimes (i (length (st :messages)))
               (let* ((msg (aref (st :messages) i)) (content (getf msg :content)))
                 (when (and content (search query content :test #'char-equal))
                   (push i matches))))
             (setf matches (nreverse matches))
             (setf (st :search-mode) t (st :search-query) query
                   (st :search-matches) matches (st :search-match-idx) 0)
             (add-msg :system (format nil "Search: ~d matches for '~a' (1/~d)" (length matches) query (length matches)))
             (when matches (setf (st :scroll-offset) (first matches)))
             (setf (st :dirty) (list nil t nil))))))
    ((string-equal text "/help")
     (add-msg :system "Commands:") (add-msg :system "/undo  /redo  /reconnect  /focus  /scope  /unfocus  /theme  /why  /quit  /help  Ctrl+G"))
    ((string-equal text "/theme")
     (add-msg :system (format nil "Theme: user-fg=~a agent-fg=~a system=~a input-fg=~a"
                             (theme-color :user-fg) (theme-color :agent-fg) (theme-color :system) (theme-color :input-fg)))
     (add-msg :system "Presets: amber gold terracotta sepia nord-warm monokai-warm gruvbox-warm light-amber catppuccin tokyonight dracula gemini mono"))
    ((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" name)))))
    ((string-equal text "/eval")
     (add-msg :system "Usage: /eval (expr)  Evaluate Lisp"))
    ((and (>= (length text) 6) (string-equal (subseq text 0 6) "/eval ") (> (length text) 6))
     (let ((code (subseq text 6)))
       (handler-case
           (let ((result (eval (let ((*read-eval* nil)) (read-from-string code)))))
             (add-msg :system (format nil "=> ~a" result)))
         (error (c) (add-msg :system (format nil "Eval error: ~a" c))))))
    ((string-equal text "/audit")
     (add-msg :system "/audit <id>  Inspect memory. /audit verify  check integrity."))
    ((and (>= (length text) 7) (string-equal (subseq text 0 7) "/audit "))
     (let ((arg (string-trim '(#\Space) (subseq text 7))))
       (if (string-equal arg "verify")
           (let* ((r (passepartout::audit-verify-hash)) (total (car r)) (missing (cdr r)))
             (add-msg :system (format nil "Memory: ~d objects, ~d missing hashes" total missing)))
           (let ((info (passepartout::audit-node arg)))
             (if info (add-msg :system (format nil "Node ~a: type=~a version=~a hash=~a scope=~a"
                                              (getf info :id) (getf info :type) (getf info :version) (getf info :hash) (getf info :scope)))
                 (add-msg :system (format nil "Node ~a not found" arg)))))))
    ((string-equal text "/sessions")
     (let* ((snaps (passepartout::snapshot-list)) (count (length snaps)))
       (add-msg :system (format nil "Snapshots: ~d. /rewind <n> /resume <n>" count))))
    ((and (>= (length text) 8) (string-equal (subseq text 0 8) "/rewind "))
     (let ((n (ignore-errors (parse-integer (string-trim '(#\Space) (subseq text 8))))))
       (if n (progn (passepartout::rollback-memory n) (add-msg :system (format nil "Rolled back to snapshot ~d" n)))
           (add-msg :system "Usage: /rewind <number>"))))
    ((and (>= (length text) 7) (string-equal (subseq text 0 7) "/resume "))
     (let ((n (ignore-errors (parse-integer (string-trim '(#\Space) (subseq text 7))))))
       (if (and n (< n (length (symbol-value 'passepartout::*memory-snapshots*))))
           (progn (passepartout::rollback-memory n) (add-msg :system (format nil "Resumed snapshot ~d" n)))
           (add-msg :system "Usage: /resume <number>"))))
    ((or (string-equal text "/q") (string-equal text "/quit"))
     (save-history)
     (add-msg :system "* Goodbye *")
     (send-daemon (list :type :event :payload '(:action :quit)))
     (setf (st :running) nil))
    ((string-equal text "/reconnect")
     (disconnect-daemon) (add-msg :system "* Reconnecting... *") (connect-daemon)
     (setf (st :dirty) (list t t nil)))
    ((string-equal text "/context")
     (add-msg :system "Context summary: /context why <id> or /context dropped"))
    ((string-equal text "/tags")
     (let ((tags (or (uiop:getenv "TAG_CATEGORIES") (uiop:getenv "PRIVACY_FILTER_TAGS") "@personal")))
       (add-msg :system (format nil "Tags: ~a" tags))))
    (t
     (add-msg :user text)
     (setf (st :busy) t)
     (send-daemon (list :type :event :payload (list :sensor :user-input :text text)))))))

(defun unified-menu-show (&optional initial-filter)
  "Open the command minibuffer with ALL commands. If INITIAL-FILTER is
supplied (e.g. \"/\"), pre-fill the select filter with it."
  (let* ((on-select (lambda (opt)
                      (pop (st :dialog-stack))
                      (let ((val (getf opt :value)))
                         (cond ((stringp val)
                                ;; Slash command — fill input buffer
                                (setf (input-text) val)
                                (setf (st :dirty) (list nil nil t)))
                              ((listp val)
                               ;; Daemon action — send immediately
                               (send-daemon (list :type :event :payload val))
                               (add-msg :system (format nil "Sent: ~a" (getf opt :title)))
                               (setf (st :dirty) (list t t nil)))))))
         (sel (cl-tty.dialog:make-select :options (all-commands) :on-select on-select)))
    (when initial-filter
      (setf (cl-tty.dialog:select-filter sel) initial-filter))
    (let ((dlg (make-instance 'cl-tty.dialog:dialog :title "Commands" :content sel)))
      (push dlg (st :dialog-stack)))))

;; v0.7.2 — resolve-hitl-panel: marks panel as resolved after approve/deny
(defun resolve-hitl-panel (decision)
  "Mark the most recent HITL panel message as resolved with DECISION."
  (loop for i from (1- (length (st :messages))) downto 0
        for m = (aref (st :messages) i)
        when (and (getf m :panel) (not (getf m :panel-resolved)))
        do (setf (getf m :panel-resolved) decision)
           (setf (aref (st :messages) i) m)
           (setf (st :dirty) (list nil t nil))
           (loop-finish)))

;; v0.7.2 — self-help-lookup: read USER_MANUAL.org and find matching sections
(defun self-help-lookup (topic)
  "Search USER_MANUAL.org for headlines matching TOPIC, return content previews."
  (let* ((manual-path (merge-pathnames "projects/passepartout/docs/USER_MANUAL.org"
                                       (merge-pathnames "memex/" (user-homedir-pathname))))
         (results nil))
    (handler-case
        (let* ((text (uiop:read-file-string manual-path))
               (lines (uiop:split-string text :separator '(#\Newline)))
               (in-section nil)
               (section-content nil))
          (dolist (line lines)
            (let ((trimmed (string-trim '(#\Space #\Tab) line)))
              (cond
                ;; New headline
                ((and (>= (length trimmed) 2) (eql (char trimmed 0) #\*))
                 (when (and in-section section-content)
                   (push (cons in-section (string-trim '(#\Space #\Newline)
                                                       (format nil "~{~a~^ ~}" (reverse section-content))))
                         results))
                 (let ((title (string-trim '(#\Space #\*) trimmed)))
                   (if (search topic title :test #'char-equal)
                       (setf in-section title
                             section-content nil)
                       (setf in-section nil
                             section-content nil))))
                ;; Content line in matching section
                (in-section
                 (when (and (> (length trimmed) 0)
                            (not (eql (char trimmed 0) #\#)))
                   (push trimmed section-content))))))
          (when (and in-section section-content)
            (push (cons in-section (string-trim '(#\Space #\Newline)
                                                (format nil "~{~a~^ ~}" (reverse section-content))))
                  results))
          (nreverse results))
      (error (c) (list (cons "Error" (format nil "Cannot read manual: ~a" c)))))))

(defun on-daemon-msg (msg)
  (let* ((payload (getf msg :payload))
         (text (getf payload :text))
         (msg-type (getf msg :type))
         (action (getf payload :action))
         (level (getf msg :level))
         (gate-trace (getf msg :gate-trace))
         (rule-count (getf payload :rule-count))
         (foveal-id (getf payload :foveal-id))
         (session-cost (getf payload :session-cost)))
    ;; v0.7.2: HITL approval-required panel
    (when (eq level :approval-required)
      (let* ((hitl-msg (or (getf payload :message)
                           (getf payload :text)
                           "HITL approval required"))
             (hitl-action (getf (getf payload :action) :payload))
             (tool-name (getf hitl-action :tool))
             (explanation (or tool-name "unknown action")))
        (add-msg :system (format nil "┌─ Permission Required ─┐~%~a~%Action: ~a~%Respond: /approve HITL-xxxx or /deny HITL-xxxx"
                                hitl-msg explanation)
                 :panel t))
      (setf (st :dirty) (list nil t nil))
      (return-from on-daemon-msg nil))
    ;; 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))
    (when session-cost (setf (st :session-cost) session-cost))
    (cond
      (text (setf (st :busy) nil)
            (add-msg :agent text :gate-trace gate-trace))
      ((eq action :handshake)
       (setf (st :daemon-version) (getf payload :version)))
      (t (add-msg :agent (format nil "~a" msg))))))

Daemon Communication

(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))))))

Connection

;; Process a key-event: route through dialog, keymap, navigation, or text-input.
(defun process-key-event (event)
  (let* ((k (cl-tty.input:key-event-key event)))
    (cond
      ((st :dialog-stack)
       (let* ((dlg (car (st :dialog-stack)))
              (sel (cl-tty.dialog:dialog-content dlg)))
         (if (cl-tty.dialog:select-handle-key sel event)
             ;; select-handle-key handled nav or enter + fired callback
             (when (eql k :enter)
               (pop (st :dialog-stack)))
             ;; not handled: escape, char input, backspace
             (cond
               ((eql k :escape)
                (pop (st :dialog-stack)))
               ((let ((ch (code-char (cl-tty.input:key-event-code event))))
                  (and ch (graphic-char-p ch)
                       (setf (cl-tty.dialog:select-filter sel)
                             (concatenate 'string
                               (or (cl-tty.dialog:select-filter sel) "")
                               (string ch))))))
               ((eql k :backspace)
                (let ((f (cl-tty.dialog:select-filter sel)))
                  (when (> (length (or f "")) 0)
                    (setf (cl-tty.dialog:select-filter sel)
                          (subseq f 0 (1- (length f)))))))))
         (setf (st :dirty) (list t t nil))))
      ((cl-tty.input:dispatch-key-event event)
       (setf (st :dirty) (list t t nil)))
      ((member k '(:ppage :npage))
       (if (eq k :ppage) (handle-ppage) (handle-npage)))
      (t (handler-case
             (progn
               (cl-tty.input:handle-text-input (st :text-input) event)
               (setf (st :dirty) (list nil nil t)))
           (error (c)
             (add-msg :system (format nil "* Input error: ~a *" c))))))))

(defun connect-daemon (&optional (host "127.0.0.1") (start-port 9105) (end-port 9115))
  "Try to connect to daemon once across START-PORT to END-PORT.
Returns T on success, nil on failure. Does NOT wait or retry."
  (loop for port from start-port to end-port
        do (handler-case
               (let ((s (usocket:socket-connect host port :timeout 2)))
                 (setf (st :stream) (usocket:socket-stream s)
                       (st :connected) t)
                 (bt:make-thread (lambda () (reader-loop (st :stream)))
                                :name "tui-reader")
                 (return-from connect-daemon t))
             (usocket:connection-refused-error () nil)
             (error (c) nil)))
  nil)

(defun disconnect-daemon ()
  (when (st :stream)
    (ignore-errors (close (st :stream)))
    (setf (st :stream) nil (st :connected) nil)
    (add-msg :system (format nil "* Disconnected [now=~a] *" (now)))))

Main Loop

;; v0.8.0 — Global keymap
(eval-when (:load-toplevel :execute)
  (cl-tty.input:defkeymap :global
    (:ctrl+q (lambda (e) (declare (ignore e))
               (setf (st :running) nil)))
    (:ctrl+p (lambda (e) (declare (ignore e))
               (unified-menu-show)))
    (:ctrl+b (lambda (e) (declare (ignore e))
               (setf (st :sidebar-mode)
                     (case (st :sidebar-mode)
                       (:auto :visible)
                       (:visible :hidden)
                       (:hidden :auto)))
               (setf (st :dirty) (list t t nil))))
    (:ppage (lambda (e) (declare (ignore e))
              (let ((max-offset (max 0 (- (length (st :messages)) 1))))
                (setf (st :scroll-offset) (min max-offset (+ (st :scroll-offset) 10))))
              (setf (st :dirty) (list nil t nil))))
    (:npage (lambda (e) (declare (ignore e))
              (setf (st :scroll-offset) (max 0 (- (st :scroll-offset) 10)))
              (setf (st :dirty) (list nil t nil))))
    ;; v0.9.0 — Readline keybindings (Ctrl+A/E/U/W/K handled by text-input widget)
    (:ctrl+y (lambda (e) (declare (ignore e))
              (let ((killed (st :kill-ring)))
                (when killed
                  (let ((cur (input-text)))
                    (setf (input-text) (concatenate 'string cur killed)))
                  (setf (st :dirty) (list nil nil t))))))
    (:ctrl+l (lambda (e) (declare (ignore e))
              (setf (st :dirty) (list t t t))))
    (:ctrl+d (lambda (e) (declare (ignore e))
              (when (string= "" (input-text))
                (add-msg :system "Goodbye. Run /quit or press Ctrl+D again to exit."))))
    (:ctrl+f (lambda (e) (declare (ignore e))
              (add-msg :system "Use /search <query> to find messages")))
    (:ctrl+g (lambda (e) (declare (ignore e))
              (let ((gate-idx nil))
                (loop for i from (1- (length (st :messages))) downto 0
                      for m = (aref (st :messages) i)
                      when (and (getf m :gate-trace) (listp (getf m :gate-trace)))
                      do (setf gate-idx i) (loop-finish))
                (if gate-idx
                    (let ((cg (st :collapsed-gates)))
                      (if (member gate-idx cg)
                          (setf (st :collapsed-gates) (remove gate-idx cg))
                          (push gate-idx (st :collapsed-gates)))
                      (add-msg :system (format nil "Gate trace ~a for msg ~a"
                                              (if (member gate-idx (st :collapsed-gates)) "hidden" "shown")
                                              gate-idx))
                      (setf (st :dirty) (list nil t nil)))
                    (add-msg :system "No gate trace to toggle")))))
    (:alt+enter (lambda (e) (declare (ignore e))
                  (let ((cur (input-text)))
                    (setf (input-text) (concatenate 'string cur (string #\Newline))))
                  (setf (st :dirty) (list nil nil t))))
    ;; v0.9.0 — Ctrl+X prefix + help
    (:ctrl+x (lambda (e) (declare (ignore e))
              (setf (st :pending-ctrl-x) t)))
    (:? (lambda (e) (declare (ignore e))
          (add-msg :system "Keybindings: Ctrl+P palette | Ctrl+B sidebar | Ctrl+F search | Ctrl+L redraw | Ctrl+D quit | Ctrl+Q quit | PageUp/Dn scroll | Esc interrupt | Tab complete | Up/Dn history")
          (add-msg :system "Commands: /eval <expr> | /undo | /redo | /why | /identity | /tags | /audit | /search | /context | /focus | /scope | /unfocus | /theme | /reconnect | /help")
          (setf (st :dirty) (list t t nil))))))
 
;; v0.8.0 — Prompt/local keymap (for when input is active)
(eval-when (:load-toplevel :execute)
  (cl-tty.input:defkeymap :local
    (:ppage (lambda (e) (declare (ignore e)) (handle-ppage)))
    (:npage (lambda (e) (declare (ignore e)) (handle-npage))))

(defvar *cat-proc* nil "Cat subprocess for keyboard input (unused — direct stdin reads)")
(defvar *tty-in* nil "Stream from cat subprocess stdout (unused — direct stdin reads)")

(defun make-text-input-with-callbacks ()
  "Create a text-input widget with the standard passepartout callbacks."
  (cl-tty.input:make-text-input
    :on-submit #'handle-submit
    :on-cancel #'handle-cancel
    :on-tab #'handle-tab
    :on-history #'handle-history))

(defun tui-main ()
  (init-state)
  (setf (st :text-input) (make-text-input-with-callbacks))
  (load-history)
  (theme-load)
  (let* ((swank-port (or (ignore-errors
                           (parse-integer (uiop:getenv "TUI_SWANK_PORT")))
                         4006)))
    (setf (st :dirty) (list t t t))
    ;; Quick sync connect attempt (just 3 ports, 6s max)
    (let ((connected (connect-daemon "127.0.0.1" 9105 9107)))
      (unless connected
        (add-msg :system "* Daemon not found — will retry in background... *")))
    (when (> swank-port 0)
      (handler-case
          (progn
             (ql:quickload :swank :silent t)
             (let ((*standard-output* (make-string-output-stream))
                   (*error-output* (make-string-output-stream)))
               (funcall (find-symbol "CREATE-SERVER" "SWANK")
                        :port swank-port :dont-close t))
              (values))
        (error ()
          (add-msg :system "* Swank unavailable *"))))
    (cl-tty.backend:with-terminal (be w h)
           ;; stty -icanon -echo -ixon is set by the bash script.
           ;; We read directly from SBCL's stdin (fd 0) since the
           ;; terminal is in raw mode — no cat subprocess needed.
           ;; Initial dirty all to trigger first redraw in loop
           (setq w (or (and (numberp w) (> w 0) w) 80)
                 h (or (and (numberp h) (> h 0) h) 24))
           ;; Retry daemon connection in background if sync attempt failed
           (unless (st :connected)
             (add-msg :system "* Connecting to daemon... *")
             (bt:make-thread
                 (lambda ()
                   (loop while (and (st :running) (not (st :connected)))
                         do (connect-daemon)
                         (unless (st :connected) (sleep 5))))
                  :name "daemon-auto-connect"))
            ;; Initial render before first read-event (which may block)
            (unless (st :dialog-stack)
              (redraw be w h))
            (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 *"))
                    ((eq (getf ev :type) :key)
                     (let* ((payload (getf ev :payload))
                            (ch (getf payload :ch)))
                       (case ch
                         (:CTRL-Q (setf (st :running) nil))
                          (:CTRL-P (unified-menu-show))
                          (:CTRL-B (setf (st :sidebar-mode)
                                         (case (st :sidebar-mode)
                                           (:auto :visible)
                                           (:visible :hidden)
                                           (:hidden :auto)))
                                   (setf (st :dirty) (list t t t)))
                         (:CTRL-L (setf (st :dirty) (list t t t)))
                         (t (if (st :dialog-stack)
                                (let* ((dlg (car (st :dialog-stack)))
                                       (sel (cl-tty.dialog:dialog-content dlg)))
                                  (cond
                                     ((eql ch :escape)
                                      (pop (st :dialog-stack))
                                      (setf (st :dirty) (list t t nil)))
                                    ((member ch '(:up :down))
                                     (if (eql ch :up)
                                         (cl-tty.dialog:select-prev sel)
                                         (cl-tty.dialog:select-next sel)))
                                     ((member ch '(:enter))
                                      (let* ((filtered (cl-tty.dialog:select-filtered-options sel))
                                             (idx (cl-tty.dialog:select-selected-index sel))
                                             (item (when (< idx (length filtered))
                                                     (third (nth idx filtered)))))
                                        (when item
                                          (let ((cb (cl-tty.dialog:select-on-select sel)))
                                            (when cb (funcall cb item))))))
                                      ((let ((chr (if (characterp ch) ch (code-char ch))))
                                          (and chr (graphic-char-p chr))
                                         (setf (cl-tty.dialog:select-filter sel)
                                               (concatenate 'string
                                                 (or (cl-tty.dialog:select-filter sel) "")
                                                 (string chr)))))
                                     ((member ch '(:backspace))
                                     (let ((f (cl-tty.dialog:select-filter sel)))
                                       (when (> (length f) 0)
                                         (setf (cl-tty.dialog:select-filter sel)
                                               (subseq f 0 (1- f))))))))
                                 nil))))))))
                 ;; Keyboard reader via cl-tty.input:read-event (handles CSI, SS3, UTF-8, resize)
                 (handler-case
                     (multiple-value-bind (ev resize-data)
                         (cl-tty.input:read-event be :timeout 0.1)
                       (cond
                         ((eq ev :resize)
                          (let ((new-size resize-data))
                            (setq w (car new-size) h (cdr new-size))
                            (setf (st :dirty) (list t t t))))
                          ((cl-tty.input:key-event-p ev)
                            (process-key-event ev))))
                    (error (c)
                      (add-msg :system (format nil "* Reader error: ~a *" c))))
                 ;; Guard w and h before render (resize or other code may have set them to nil)
               (setq w (or (and (numberp w) (> w 0) w) 80)
                     h (or (and (numberp h) (> h 0) h) 24))
                                (unless (st :dialog-stack)
                  (redraw be w h))
               (let ((ds (st :dialog-stack)))
                  (when ds
                     (cl-tty.backend:begin-sync be)
                     (let* ((chat-w (- w (if (sidebar-visible-p w) (or (st :sidebar-width) 42) 0)))
                            (dlg (car ds))
                            (sel (cl-tty.dialog:dialog-content dlg))
                            (filtered (cl-tty.dialog:select-filtered-options sel))
                            (sel-idx (cl-tty.dialog:select-selected-index sel))
                            (cnt (length filtered))
                            (filter (cl-tty.dialog:select-filter sel))
                            (mh (min 15 (+ 1 cnt)))
                            (panel-top (passepartout.channel-tui:input-panel-top chat-w h))
                            (top (max 0 (- panel-top mh)))
                            (bg-p (theme-color :bg-panel))
                            (sep-c (theme-color :separator)))
                       ;; Fill minibuffer area with panel bg
                       (dotimes (r (min (- h 3 top) h))
                         (cl-tty.backend:draw-rect be 0 (+ top r) chat-w 1 :bg bg-p))
                        ;; Top separator
                        (cl-tty.backend:draw-text be 0 top
                                                  (make-string chat-w :initial-element #\─)
                                                  sep-c bg-p)
                        (cl-tty.backend:draw-text be 1 top
                                                  (cl-tty.dialog:dialog-title dlg)
                                                  (theme-color :accent) bg-p)
                         ;; Options
                         (let ((y-off 1))
                           (dolist (item filtered)
                             (let* ((display-idx (first item))
                                    (option (third item))
                                    (title (getf option :title))
                                    (cat (getf option :category))
                                    (sel-p (eql display-idx (or sel-idx 0)))
                                    (text (if cat (format nil "  ~a" title)
                                              (format nil "    ~a" title)))
                                    (row (+ top y-off)))
                               (when (>= row (1- h)) (return))
                               (cond
                                 (sel-p
                                  (cl-tty.backend:draw-rect be 1 row (1- chat-w) 1
                                                            :bg (theme-color :input-fg))
                                  (cl-tty.backend:draw-text be 1 row (format nil "  >> ~a" title)
                                                            (theme-color :bg-input) (theme-color :input-fg)))
                                 (cat
                                  (cl-tty.backend:draw-text be 1 row text
                                                            (theme-color :text-muted) bg-p))
                                 (t
                                  (cl-tty.backend:draw-text be 1 row text
                                                            (theme-color :agent-fg) bg-p)))
                               (incf y-off))))
                        (cl-tty.backend:draw-rect be 0 (- h 3) chat-w 1 :bg bg-p)
                        (cl-tty.backend:draw-text be 0 (- h 3)
                                                  (format nil "> ~a" (or filter ""))
                                                  (theme-color :input-prompt) bg-p))
                      (cl-tty.backend:end-sync be))
                   (sleep 0.1)))
                (progn (disconnect-daemon)))))

Test Suite

(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-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: *theme* provides color mappings via theme-color."
  (fiveam:is (string= "#fab283" (theme-color :user-fg)))
  (fiveam:is (string= "#e8e8e8" (theme-color :agent-fg)))
  (fiveam:is (string= "#808080" (theme-color :system)))
  (fiveam:is (string= "#e8e8e8" (theme-color :input-fg)))
  (fiveam:is (string= "#FFFFFF" (theme-color :unknown-role))))

(fiveam:test test-on-key-ctrl-l-redraws
  "Contract v0.9.0: Ctrl+L (via dispatch-key-event) sets all dirty flags."
  (init-state)
  (setf (st :dirty) (list nil nil nil))
  (cl-tty.input:dispatch-key-event
   (cl-tty.input:make-key-event :key :l :ctrl t :code 12))
  (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 "amber" (input-text) :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 Panels ──

(fiveam:test test-hitl-panel-in-on-daemon-msg
  "Contract v0.7.2: approval-required messages render as HITL panels."
  (init-state)
  (on-daemon-msg '(:type :EVENT :level :approval-required
                    :payload (:sensor :approval-required
                              :action (:TYPE :REQUEST :PAYLOAD (:TOOL "shell"))
                              :message "rm -rf blocked")))
  (let ((m (aref (st :messages) 0)))
    (fiveam:is (eq :system (getf m :role)))
    (fiveam:is (getf m :panel))
    (fiveam:is (search "rm -rf" (getf m :content)))))

(fiveam:test test-hitl-panel-after-approve
  "Contract v0.7.2: /approve adds confirmation and marks panel resolved."
  (init-state)
  (on-daemon-msg '(:type :EVENT :level :approval-required
                    :payload (:sensor :approval-required :message "test")))
  (dolist (ch (coerce "/approve HITL-test" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  ;; Panel message (index 0) should be marked resolved
  (let ((m (aref (st :messages) 0)))
    (fiveam:is (getf m :panel))
    (fiveam:is (eq :approved (getf m :panel-resolved))))
  ;; Last message should be the approval confirmation
  (let ((m (aref (st :messages) (1- (length (st :messages))))))
    (fiveam:is (search "Approved" (getf m :content)))))

(fiveam:test test-hitl-panel-after-deny
  "Contract v0.7.2: /deny marks panel as denied."
  (init-state)
  (on-daemon-msg '(:type :EVENT :level :approval-required
                    :payload (:sensor :approval-required :message "blocked")))
  (dolist (ch (coerce "/deny HITL-deny" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  (let ((m (aref (st :messages) 0)))
    (fiveam:is (getf m :panel))
    (fiveam:is (eq :denied (getf m :panel-resolved)))))

(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)))))

;; ── v0.7.2 Undo/Redo ──

(fiveam:test test-undo-command
  "Contract v0.7.2: /undo sends undo event."
  (init-state)
  (dolist (ch (coerce "/undo" '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 "Undo" (getf m :content)))))

(fiveam:test test-redo-command
  "Contract v0.7.2: /redo sends redo event."
  (init-state)
  (dolist (ch (coerce "/redo" '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 "Redo" (getf m :content)))))

;; ── v0.7.2 Self-help ──

(fiveam:test test-why-command
  "Contract v0.7.2: /why shows gate trace from last message."
  (init-state)
  (add-msg :agent "did something" :gate-trace '((:gate "shell" :result :blocked :reason "rm -rf")))
  (dolist (ch (coerce "/why" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  (let* ((msgs (st :messages))
         (m (aref msgs (1- (length msgs)))))
    (fiveam:is (eq :system (getf m :role)))
    (fiveam:is (search "[BLOCKED]" (getf m :content)))
    (fiveam:is (search "shell" (getf m :content)))))

(fiveam:test test-why-no-trace
  "Contract v0.7.2: /why with no gate trace shows fallback message."
  (init-state)
  (dolist (ch (coerce "/why" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  (let* ((msgs (st :messages))
         (m (aref msgs (1- (length msgs)))))
    (fiveam:is (search "No recent" (getf m :content)))))

;; ── v0.7.2 Gate Trace Toggle (Ctrl+G) ──

(fiveam:test test-ctrlg-toggle-gate-trace
  "Contract v0.9.0: Ctrl+G (via dispatch-key-event) toggles gate-trace collapse state."
  (init-state)
  (add-msg :agent "test" :gate-trace '((:gate "shell" :result :passed)))
  (cl-tty.input:dispatch-key-event
   (cl-tty.input:make-key-event :key :g :ctrl t :code 7))
  (let* ((msgs (st :messages))
         (m (aref msgs (1- (length msgs)))))
    (fiveam:is (search "hidden" (getf m :content))))
  (cl-tty.input:dispatch-key-event
   (cl-tty.input:make-key-event :key :g :ctrl t :code 7))
  (let* ((msgs (st :messages))
         (m (aref msgs (1- (length msgs)))))
    (fiveam:is (search "shown" (getf m :content)))))

(fiveam:test test-ctrlg-no-gate-trace
  "Contract v0.9.0: Ctrl+G (via dispatch-key-event) with no gate trace shows fallback."
  (init-state)
  (cl-tty.input:dispatch-key-event
   (cl-tty.input:make-key-event :key :g :ctrl t :code 7))
  (let ((m (aref (st :messages) 0)))
    (fiveam:is (search "No gate trace" (getf m :content)))))

;; ── v0.7.2 Message Search Mode ──

(fiveam:test test-search-mode-activate
  "Contract v0.7.2: /search enters search mode."
  (init-state)
  (add-msg :agent "hello world")
  (add-msg :agent "goodbye")
  (dolist (ch (coerce "/search hello" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  (fiveam:is (eq t (st :search-mode)))
  (fiveam:is (string= "hello" (st :search-query)))
  (fiveam:is (= 1 (length (st :search-matches)))))

(fiveam:test test-search-mode-escape-exits
  "Contract v0.7.2: Escape exits search mode."
  (init-state)
  (add-msg :agent "test")
  (dolist (ch (coerce "/search test" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  (fiveam:is (eq t (st :search-mode)))
  (on-key 27)  ;; Escape
  (fiveam:is (null (st :search-mode))))

(fiveam:test test-search-mode-up-down-nav
  "Contract v0.7.2: Up/Down navigates between search matches."
  (init-state)
  (add-msg :agent "aaa hello bbb")
  (add-msg :agent "ccc hello ddd")
  (add-msg :agent "no match here")
  (dolist (ch (coerce "/search hello" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  (fiveam:is (= 0 (st :search-match-idx)))
  (on-key 258)  ;; Down
  (fiveam:is (= 1 (st :search-match-idx)))
  (on-key 259)  ;; Up
  (fiveam:is (= 0 (st :search-match-idx)))
  (on-key 259)  ;; Up (clamped)
  (fiveam:is (= 0 (st :search-match-idx))))

(fiveam:test test-context-sections
  "Contract v0.7.2: /context shows section breakdown with IDENTITY, TOOLS, LOGS."
  (init-state)
  (add-msg :agent "hello world")
  (dolist (ch (coerce "/context" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  (let ((msgs (st :messages)))
    (fiveam:is (some (lambda (m) (search "IDENTITY" (getf m :content))) msgs))
    (fiveam:is (some (lambda (m) (search "LOGS" (getf m :content))) msgs))
    (fiveam:is (some (lambda (m) (search "TOOLS" (getf m :content))) msgs))))

(fiveam:test test-help-topic-lookup
  "Contract v0.7.2: /help <topic> reads and searches USER_MANUAL.org."
  (init-state)
  (dolist (ch (coerce "/help configuration" 'list))
    (on-key (char-code ch)))
  (on-key 13)
  (let ((msgs (st :messages)))
    (fiveam:is (some (lambda (m) (search ".env" (getf m :content))) msgs))))

(fiveam:test test-pads-page-up
  "Contract v0.7.2: PageUp scrolls by page size (> 5 lines)."
  (init-state)
  (dotimes (i 30) (add-msg :system (format nil "msg ~d" i)))
  (setf (st :scroll-offset) 0)
  (on-key :ppage)
  (fiveam:is (> (st :scroll-offset) 5) "Should scroll by more than 5 lines"))

(fiveam:test test-pads-page-down-clamp
  "Contract v0.7.2: PageDown clamps to 0."
  (init-state)
  (dotimes (i 5) (add-msg :system (format nil "msg ~d" i)))
  (setf (st :scroll-offset) 3)
  (on-key :npage)
  (fiveam:is (= 0 (st :scroll-offset))))

;; ── v0.8.0 Minibuffer ──

(fiveam:test test-slash-commands-defined
  "Contract v0.8.0: *slash-commands* is non-nil list of option plists."
  (fiveam:is (listp passepartout.channel-tui::*slash-commands*))
  (fiveam:is (> (length passepartout.channel-tui::*slash-commands*) 0))
  (fiveam:is (every (lambda (opt)
                      (and (getf opt :title) (getf opt :value) (getf opt :category)))
                    passepartout.channel-tui::*slash-commands*)))

(fiveam:test test-minibuffer-state
  "Contract v0.8.0: init-state has :dialog-stack and :minibuffer-active fields."
  (init-state)
  (fiveam:is (null (st :dialog-stack)))
  (fiveam:is (null (st :minibuffer-active))))

(fiveam:test test-command-palette-state
  "Contract v0.8.0: init-state has :command-palette-active and :command-palette-dialog as nil."
  (init-state)
  (fiveam:is (null (st :command-palette-active)))
  (fiveam:is (null (st :command-palette-dialog))))