Files
passepartout/org/channel-tui-view.org
Amr Gharbeia 74621cffd2 fix: disable flow control (-ixon) for Ctrl+Q, constrain prompt/hint to chat-w
- Added -ixon to stty so Ctrl+Q (XON byte) isn't swallowed by the
  terminal driver and reaches the TUI as :CTRL-Q
- view-input now truncates the prompt (> prefix + visible text) to
  chat-w - 2 characters, and the hint to chat-w characters, so
  neither extends into the sidebar area
2026-05-14 15:48:34 -04:00

28 KiB

Passepartout TUI — View

View

Pure render functions. Each takes the cl-tty backend and current state.
State is read via (st :key) — no mutation here.

Contract

  1. (view-status win): renders the status bar with connection info, msg count, scroll offset, rule counter, focus map (v0.4.0), and timestamp. Two lines: line 1 (status + rules), line 2 (focus + time).
  2. (view-chat win h): renders the scrolled chat message list. Takes window and available height. Messages are color-coded: green (user), white (agent), yellow (system).
  3. (view-input win): renders the input line with cursor and typing indicator.
  4. (redraw sw cw ch iw): dispatches redraws based on (st :dirty) flags (status, chat, input). Minimizes terminal writes.
  5. (char-width ch): returns the terminal column width of character CH. ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8. Used by word-wrap for accurate line counting (v0.7.0).
  6. (view-status win): v0.7.0 — timestamp right-aligned at (- chat-w 12) on line 2, focus info at :x 1. No overlap.

Status Bar

The status bar, as of v0.4.0, renders Passepartout's three differentiator visualizations — data only available because of the deterministic gate architecture:

  • Rule counter (Rules:N): the number of pending HITL actions from the Dispatcher's *hitl-pending* hash table. The user watches this tick up as they teach the agent their preferences through approve/deny decisions.
  • Focus map ([Focus: <id>]): the foveal focus from the daemon's signal context. Shows the user what the agent is currently looking at.
  • Gate trace (not rendered in status bar — attached to individual messages via :gate-trace field for future collapsible rendering per message).

All three enrichments cost 0 LLM tokens — they are daemon-state queries that the TUI actuator attaches to the response plist before transmission.

(in-package :passepartout.channel-tui)

(defun word-wrap (text width)
  "Wrap TEXT to at most WIDTH columns. Splits on word boundaries.
Returns a list of strings, one per line."
  (let ((lines nil))
    (loop while (> (length text) width)
          do (let ((break (or (position #\Space text :end width :from-end t)
                              width)))
               (push (subseq text 0 break) lines)
               (setf text (string-left-trim '(#\Space)
                            (subseq text break)))))
    (push text lines)
    (nreverse lines)))

(defun view-status (fb w h)
  (let* ((w (or (and (numberp w) (> w 0) w) 80))
          (h (or (and (numberp h) (> h 0) h) 24))
         (sidebar-w (if (and (st :sidebar-visible) (>= w 60)) (or (st :sidebar-width) 30) 0))
         (chat-w (- w sidebar-w))
         (bg (theme-color :status-bg))
         (fg (theme-color :status-fg))
         (ver (st :daemon-version))
         (ver-str (if ver (format nil " v~a" ver) ""))
         (left (format nil " ~a ~a~a  msgs:~d Rules:~a"
                      (if (st :connected) "●" "○")
                      (or (st :foveal-id) "passepartout")
                      ver-str
                      (length (st :messages))
                      (or (st :rule-count) 0)))
         (right (format nil "$~,2f  ~a" (or (st :session-cost) 0.0) (now))))
    (cl-tty.backend:draw-rect fb 0 (- h 1) chat-w 1 :bg bg)
    (cl-tty.backend:draw-text fb 1 (- h 1) left fg nil)
    (cl-tty.backend:draw-text fb (- chat-w (length right) 2) (- h 1) right fg nil)))


;; v0.7.2: search-highlight — wrap matching text in **bold** for markdown
(defun search-highlight (content query)
  "Wrap occurrences of QUERY in CONTENT with **bold** markers."
  (let ((lower-content (string-downcase content))
        (lower-query (string-downcase query))
        (result "") (pos 0))
    (when (and query (> (length query) 0))
      (loop
        (let ((found (search lower-query lower-content :start2 pos)))
          (unless found (return))
          (setf result (concatenate 'string result
                                   (subseq content pos found)
                                   "**" (subseq content found (+ found (length query))) "**"))
          (setf pos (+ found (length query)))))
      (setf result (concatenate 'string result (subseq content pos)))
      (if (string= result "") content result))))

(defun view-chat (fb w h)
  (let* ((w (or (and (numberp w) (> w 0) w) 80))
         (h (or (and (numberp h) (> h 0) h) 24))
         (sidebar-w (if (and (st :sidebar-visible) (>= w 60))
                        (or (st :sidebar-width) 30) 0))
         (chat-w (- w sidebar-w))
         (msgs (st :messages)) (total (length msgs))
         (max-lines (- h 4)) (is-search (st :search-mode)) (y 0))
    (when is-search
      (let* ((matches (st :search-matches)) (idx (st :search-match-idx))
             (query (st :search-query))
             (hdr (format nil "Search: ~d matches for '~a' (~d/~d) — Esc to exit"
                          (length matches) query (1+ idx) (length matches))))
        (cl-tty.backend:draw-text fb 1 y hdr (theme-color :accent) nil)
        (incf y) (decf max-lines)))
    (let ((msg-lines (make-array total)) (msg-heights (make-array total)))
      (dotimes (i total)
        (let* ((msg (aref msgs i)) (role (getf msg :role))
               (content (getf msg :content)) (time (or (getf msg :time) ""))
               (cs (if is-search (search-highlight content (st :search-query)) content))
               (pairs nil))
          (case role
            (:user
             (let* ((top (format nil "┌─ you ~a ─" time))
                    (top-str (format nil "~a~a┐" top
                                     (make-string (max 0 (- chat-w (length top) 1)) :initial-element #\─)))
                    (body (cl-tty.box:word-wrap cs (- chat-w 4)))
                    (pad (- chat-w 3))
                    (bot (format nil "└~a┘" (make-string (max 0 pad) :initial-element #\─)))
                    (bdr (theme-color :user-border)))
               (push (list top-str bdr) pairs)
               (dolist (l body)
                 (push (list (format nil "│ ~a~a│" l
                                     (make-string (max 0 (- pad (length l))) :initial-element #\Space))
                             (theme-color :user-fg) (theme-color :user-bg)) pairs))
               (push (list bot bdr) pairs)))
            (:agent
             (let* ((hdr (format nil "── passepartout ~a " time))
                    (hdr-str (format nil "~a~a" hdr
                                     (make-string (max 0 (- chat-w (length hdr))) :initial-element #\─)))
                    (nodes (cl-tty.markdown:parse-blocks cs))
                    (raw-body (or (and nodes (cl-tty.markdown:render-md nodes)) (list "")))
                    (body (mapcan (lambda (l) (cl-tty.box:word-wrap l (- chat-w 2))) raw-body)))
               (push (list hdr-str (theme-color :agent-header)) pairs)
               (dolist (l body) (push (list l (theme-color :agent-fg)) pairs))))
            (t (dolist (l (cl-tty.box:word-wrap cs (- chat-w 2)))
                 (push (list l (theme-color :system)) pairs))))
          (let ((gt (getf msg :gate-trace)))
            (when (and gt (eq role :agent))
              (if (member i (st :collapsed-gates))
                  (push (list (format nil "╎ Gate trace: ~a gates" (length gt))
                              (theme-color :dim)) pairs)
                  (dolist (entry (passepartout::gate-trace-lines gt))
                    (push (list (concatenate 'string "╎ " (car entry))
                                (theme-color (getf (cdr entry) :fgcolor))) pairs)))))
          (let ((tc (getf msg :tool-calls)))
            (when tc
              (if (member i (st :collapsed-tools))
                  (let* ((n (or (getf (first tc) :name) "tool"))
                         (d (or (getf (first tc) :duration) 0.0))
                         (extra (reduce #'+ tc :key
                                        (lambda (c) (length (cl-tty.box:word-wrap
                                                             (or (getf c :output) "") (- chat-w 6)))))))
                    (push (list (format nil "┌─ ~a ──── ~,1fs ── [+~d more] ────────┐" n d extra)
                                (theme-color :tool-done)) pairs))
                  (dolist (call tc)
                    (let* ((name (or (getf call :name) "tool"))
                           (dur (or (getf call :duration) 0.0))
                           (st (getf call :status))
                           (out (getf call :output))
                           (bc (theme-color
                                (cond ((eq st :running) :tool-running)
                                      ((eq st :error) :tool-error)
                                      (t :tool-done))))
                           (pfx (cond ((eq st :error) "✗") ((eq st :running) "●") (t "✓")))
                           (ol (when out (cl-tty.box:word-wrap out (- chat-w 6))))
                           (top (format nil "┌─ ~a ──── ~,1fs ─" name dur))
                           (top-str (format nil "~a~a┐" top
                                            (make-string (max 0 (- chat-w (length top) 1)) :initial-element #\─)))
                           (bot (format nil "└~a┘" (make-string (max 0 (- chat-w 2)) :initial-element #\─))))
                      (push (list top-str bc) pairs)
                      (dolist (l ol)
                        (push (list (format nil "│ ~a ~a~a│" pfx l
                                            (make-string (max 0 (- chat-w (length pfx) (length l) 4))
                                                         :initial-element #\Space)) bc) pairs))
                      (push (list bot bc) pairs))))))
          (when (> i 0)
            (let ((pt (or (getf (aref msgs (1- i)) :time) "")))
              (flet ((h (s) (if (> (length s) 0) (subseq s 0 (or (position #\: s) 0)) "")))
                (let ((ph (h pt)) (ch (h time)))
                  (when (and (> (length ch) 0) (string/= ch ph))
                    (let* ((pad (max 0 (floor (- chat-w (length time) 2) 2)))
                           (rpad (- chat-w (length time) 2 pad)))
                      (push (list (format nil "~a ~a ~a"
                                          (make-string pad :initial-element #\─)
                                          time
                                          (make-string rpad :initial-element #\─))
                                  (theme-color :separator)) pairs)))))))
          (setf (aref msg-lines i) (nreverse pairs))
          (setf (aref msg-heights i) (length pairs))))
      (let ((msg-count 0) (lines-remaining max-lines))
        (loop for i from (1- total) downto 0
              while (> lines-remaining 0)
              do (let ((h (aref msg-heights i)))
                   (if (<= h lines-remaining)
                       (progn (decf lines-remaining h) (incf msg-count))
                       (setf lines-remaining 0))))
        (let* ((scroll-skip (st :scroll-offset))
               (start (max 0 (- total msg-count scroll-skip))))
          (loop for i from start below total while (< y (- h 4))
                do (let ((pairs (aref msg-lines i)))
                     (dolist (pair pairs)
                       (when (>= y (- h 4)) (return))
                       (destructuring-bind (text color &optional bg) pair
                          (when bg (cl-tty.backend:draw-text fb 0 y (make-string w :initial-element #\Space) nil bg))
                         (cl-tty.backend:draw-text fb 0 y text color nil))
                       (incf y)))))))))

Input Line

(defun view-input (fb w h)
  (let* ((w (or (and (numberp w) (> w 0) w) 80))
         (h (or (and (numberp h) (> h 0) h) 24))
         (sidebar-w (if (and (st :sidebar-visible) (>= w 60)) (or (st :sidebar-width) 30) 0))
         (chat-w (- w sidebar-w))
         (prompt-w (- chat-w 2))  ; leave room for "> "
         (text (input-string))
         (pos (or (st :cursor-pos) 0))
         (display-start (max 0 (- pos (1- prompt-w))))
         (visible (subseq text display-start (min (length text) (+ display-start prompt-w))))
         (hint " Ctrl+P palette | Up/Dn history | Tab complete")
         (hint (if (> (length hint) chat-w) (subseq hint 0 chat-w) hint)))
    (cl-tty.backend:draw-text fb 0 (- h 2) hint (theme-color :hint) nil)
    (cl-tty.backend:draw-text fb 0 (- h 3) (format nil "> ~a" visible) (theme-color :input-fg) nil)))

Sidebar

(defun view-sidebar (fb w h)
  "Render the right-side sidebar panel with warm colors."
  (let* ((w (or (and (numberp w) (> w 0) w) 80))
         (h (or (and (numberp h) (> h 0) h) 24))
         (x (- w (or (st :sidebar-width) 30)))
         (y 0))
    ;; Vertical separator
    (dotimes (row h)
      (cl-tty.backend:draw-text fb (1- x) row " " nil (theme-color :separator)))
    ;; Focus panel
    (cl-tty.backend:draw-text fb (1+ x) (incf y) " FOCUS" (theme-color :accent) nil)
    (incf y)
    (cl-tty.backend:draw-text fb (1+ x) (incf y) (format nil "  ~a" (or (st :foveal-id) "none"))
                              (theme-color :agent-fg) nil)
    (incf y 2)
    ;; Rules panel
    (cl-tty.backend:draw-text fb (1+ x) (incf y) " RULES" (theme-color :accent) nil)
    (incf y)
    (cl-tty.backend:draw-text fb (1+ x) (incf y) (format nil "  ~d active" (or (st :rule-count) 0))
                              (theme-color :agent-fg) nil)
    (incf y 2)
    ;; Context panel — token gauge
    (cl-tty.backend:draw-text fb (1+ x) (incf y) " CONTEXT" (theme-color :accent) nil)
    (incf y)
    (let* ((msg-count (max 1 (length (st :messages))))
           (est (* msg-count 60))
           (limit 8192)
           (pct (min 100 (floor (* 100 est) limit)))
           (bar-len (floor pct 10))
           (bar (make-string bar-len :initial-element #\#)))
      (cl-tty.backend:draw-text fb (1+ x) (incf y)
                               (format nil "  [~a~a]" bar
                                       (make-string (- 10 bar-len) :initial-element #\Space))
                               (theme-color :dim) nil)
      (incf y)
      (cl-tty.backend:draw-text fb (1+ x) (incf y) (format nil "  ~d%" pct)
                               (theme-color :status-fg) nil)
      (incf y 2))
    ;; MCP panel
    (cl-tty.backend:draw-text fb (1+ x) (incf y) " MCP" (theme-color :accent) nil)
    (incf y)
    (cl-tty.backend:draw-text fb (1+ x) (incf y) (format nil "  ~d server~:p" (or (st :mcp-count) 0))
                              (theme-color :agent-fg) nil)))

Redraw (dirty-flag dispatch)

(defun redraw (fb w h)
  (setq w (or (and (numberp w) (> w 0) w) 80)
        h (or (and (numberp h) (> h 0) h) 24))
  (destructuring-bind (sd cd id) (st :dirty)
    (when sd (view-status fb w h))
    (when cd (view-chat fb w h))
    (when id (view-input fb w h))
    (when (and (st :sidebar-visible) (>= w 60))
      (view-sidebar fb w h))
    (setf (st :dirty) (list nil nil nil))))

Implementation — v0.7.0 additions

(in-package :passepartout)

(defun char-width (ch)
  "Returns the terminal column width of character CH.
ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8."
  (let ((code (char-code ch)))
    (cond
      ((= code 9) 8)
      ((< code 32) 0)
      ((<= code 127) 1)
      ((<= #x4E00 code #x9FFF) 2)
      ((<= #x3400 code #x4DBF) 2)
      ((<= #x3040 code #x309F) 2)
      ((<= #x30A0 code #x30FF) 2)
      ((<= #xAC00 code #xD7AF) 2)
      ((<= #xFF01 code #xFF60) 2)
      ((<= #xFFE0 code #xFFE6) 2)
      ((<= #x1F300 code #x1F9FF) 2)
      ((<= #x2600 code #x27BF) 2)
      ((<= #x0300 code #x036F) 0)
      ((<= #x20D0 code #x20FF) 0)
      ((<= #xFE00 code #xFE0F) 0)
      (t 1))))

v0.7.1 — Markdown Rendering

(in-package :passepartout)

(defun parse-markdown-spans (text)
  "Parse inline markdown. Returns list of (text . (:bold/:underline/:code/:url ...))."
  (let ((results nil) (pos 0) (len (length text)))
    (labels ((earliest (a b) (cond ((and a (or (null b) (< a b))) a) (b b))))
      (loop
        (when (>= pos len) (return))
        (let* ((bold (search "**" text :start2 pos))
               (code (search "`" text :start2 pos))
               (italic (search "*" text :start2 pos))
               (http (search "http://" text :start2 pos))
               (https (search "https://" text :start2 pos))
               (url-s (or https http)))
          (flet ((pick (tag delim)
                   (let ((end (search delim text :start2 (+ pos (length delim)))))
                     (when end
                       (push (cons (subseq text (+ pos (length delim)) end)
                                   (case tag (:bold '(:bold t))
                                        (:code '(:code t :bgcolor :dim))
                                        (:underline '(:underline t))
                                        (:url '(:url t))))
                             results)
                       (setf pos (+ end (length delim)))
                       t)))
                 (url-end (start)
                   (or (position-if (lambda (c) (find c '(#\Space #\Newline #\Tab #\))))
                                    text :start start)
                       len)))
            (let ((next (earliest (earliest (earliest bold code) italic) url-s)))
              (cond ((and bold (eql bold next)) (unless (pick :bold "**") (incf pos 2)))
                    ((and code (eql code next)) (unless (pick :code "`") (incf pos)))
                    ((and italic (eql italic next)) (unless (pick :underline "*") (incf pos)))
                    ((and url-s (eql url-s next))
                     (let ((ue (url-end url-s)))
                       (push (cons (subseq text url-s ue) '(:url t)) results)
                       (setf pos ue)))
                    (t (push (cons (subseq text pos) nil) results) (return))))))))
    (nreverse results)))

(defun render-styled (fb segments y x w)
  "Render markdown segments to cl-tty backend. Returns next y."
  (dolist (seg segments)
    (let* ((text (or (car seg) ""))
           (attrs (cdr seg))
           (bold (getf attrs :bold))
           (code (getf attrs :code))
           (url (getf attrs :url)))
      (declare (ignore code))
      (cl-tty.backend:draw-text fb x y text
                               (cond (url (theme-color :accent))
                                     (t (theme-color (or (getf attrs :role) :agent-fg))))
                               nil
                               :bold bold)
      (incf x (length text))))
  y)

(defun parse-markdown-blocks (text)
  "Split text at ``` code block boundaries."
  (let ((r nil) (p 0) (l (length text)))
    (loop
     (when (>= p l) (return))
     (let ((bs (search "```" text :start2 p)))
       (unless bs
         (push (cons (subseq text p) nil) r)
         (return))
       (when (> bs p)
         (push (cons (subseq text p bs) nil) r))
       (let* ((ao (+ bs 3))
              (le (or (position #\Newline text :start ao) l))
              (lang (string-trim " \r\n\t" (if (< le l) (subseq text ao le) "")))
              (cs (if (< le l) (1+ le) l))
              (cp (search "```" text :start2 cs))
              (ce (or cp l))
              (content (string-trim "\r\n" (subseq text cs ce))))
         (push (list :code-block t :lang lang :content content) r)
         (setf p (if cp (+ cp 3) l)))))
    (nreverse r)))

(defun syntax-highlight (code lang)
  "Highlight Lisp code: strings, comments, keywords, function calls."
  (declare (ignore lang))
  (let* ((r nil) (p 0) (l (length code))
         (kw '("defun" "defvar" "defparameter" "let" "let*" "lambda" "if" "when" "unless"
               "cond" "loop" "dolist" "dotimes" "progn" "prog1" "return"
               "setf" "setq" "format" "and" "or" "not" "list" "cons"
               "quote" "function" "declare" "ignore" "t" "nil")))
    (flet ((wordp (c) (or (alphanumericp c) (find c "-*+/?!_=<>"))))
      (loop
       (when (>= p l) (return))
       (let* ((ss (position #\" code :start p))
              (sc (position #\; code :start p))
              (sp (position #\( code :start p))
              (next (min (or ss l) (or sc l) (or sp l))))
         (when (> next p)
           (push (cons (subseq code p next) nil) r)
           (setf p next))
         (when (>= p l) (return))
         (cond
          ((eql p ss)
           (let ((e (or (position #\" code :start (1+ p)) l)))
             (push (cons (subseq code p (min (1+ e) l)) '(:fgcolor :string)) r)
             (setf p (min (1+ e) l))))
          ((eql p sc)
           (let ((e (or (position #\Newline code :start p) l)))
             (push (cons (subseq code p e) '(:fgcolor :comment)) r)
             (setf p e)))
          ((eql p sp)
           (push (cons "(" nil) r)
           (incf p)
           (let ((fe (loop for i from p below l for c = (char code i)
                           while (wordp c) finally (return i))))
             (when (> fe p)
               (let ((fs (subseq code p fe)))
                 (push (cons fs (list :fgcolor (if (member fs kw :test #'string=)
                                                   :keyword :function))) r)
                 (setf p fe)))))))))
    (nreverse r)))

v0.7.2 — Gate Trace

(in-package :passepartout)

(defun gate-trace-lines (trace)
  "Convert gate-trace plist to display lines."
  (let ((lines nil))
    (dolist (entry trace)
      (let* ((gate (getf entry :gate))
             (result (getf entry :result))
             (reason (getf entry :reason))
             (name (or gate "unknown"))
             (color (case result
                       (:passed :tool-done)
                       (:blocked :error)
                       (:approval :accent)
                       (t :dim)))
             (prefix (case result
                       (:passed "  \u2713 ")
                       (:blocked "  \u2717 ")
                       (:approval "  \u2192 ")
                       (t "  ? ")))
             (text (format nil "~a~a~@[~a~]~@[~a~]"
                           prefix name
                           (when reason (format nil ": ~a" reason))
                           (if (eq result :approval) " (HITL required)" ""))))
        (push (cons text (list :fgcolor color)) lines)))
    (nreverse lines)))

Test Suite

(eval-when (:compile-toplevel :load-toplevel :execute)
  (ql:quickload :fiveam :silent t))

(defpackage :passepartout-tui-view-tests
  (:use :cl :fiveam :passepartout)
  (:export #:tui-view-suite))

(in-package :passepartout-tui-view-tests)

(def-suite tui-view-suite :description "TUI view rendering helpers")
(in-suite tui-view-suite)

(test test-char-width-ascii
  "Contract 5: ASCII characters (< 128) have width 1."
  (is (= 1 (passepartout::char-width #\a)))
  (is (= 1 (passepartout::char-width #\Space)))
  (is (= 1 (passepartout::char-width #\@))))

(test test-char-width-tab
  "Contract 5: tab character has width 8."
  (is (= 8 (passepartout::char-width #\Tab))))

(test test-char-width-cjk
  "Contract 5: CJK characters have width 2."
  (is (= 2 (passepartout::char-width #\日))))

(test test-char-width-null
  "Contract 5: null has width 0."
  (is (= 0 (passepartout::char-width #\Nul))))

(test test-markdown-bold
  "Contract 7: parse-markdown-spans detects **bold**."
  (let ((segments (passepartout::parse-markdown-spans "hello **world**!")))
    (is (= 3 (length segments)))))

(test test-markdown-plain
  "Contract 7: plain text returns single segment."
  (let ((segments (passepartout::parse-markdown-spans "plain")))
    (is (= 1 (length segments)))
    (is (string= "plain" (caar segments)))))

(test test-markdown-url
  "Contract 7: parse-markdown-spans detects URLs."
  (let ((segments (passepartout::parse-markdown-spans "see https://example.com for more")))
    (is (>= (length segments) 2))
    (is (find t segments :key (lambda (s) (getf (cdr s) :url))))))

(test test-markdown-blocks
  "Contract 8: parse-markdown-blocks detects code blocks."
  (let* ((text (format nil "before~%```lisp~%(+ 1 2)~%```~%after"))
         (segs (passepartout::parse-markdown-blocks text)))
    (is (= 3 (length segs)))
    (let ((code (second segs)))
      (is (eq t (getf code :code-block)))
      (is (string= "lisp" (getf code :lang)))
      (is (string= "(+ 1 2)" (string-trim '(#\Space #\Newline) (getf code :content)))))))

(test test-markdown-blocks-no-close
  "Contract 8: unclosed code block returns content."
  (let* ((text (format nil "```~%unclosed code"))
         (segs (passepartout::parse-markdown-blocks text)))
    (is (= 1 (length segs)))
    (is (eq t (getf (first segs) :code-block)))))

(test test-syntax-highlight
  "Contract 9: syntax-highlight colors Lisp code."
  (let ((segs (passepartout::syntax-highlight "(defun foo (x) (+ x 1))" "lisp")))
    (is (>= (length segs) 3))))

(test test-syntax-highlight-keyword
  "Contract 9: syntax-highlight colors keywords."
  (let ((segs (passepartout::syntax-highlight "(let ((x 1)) (+ x 2))" "lisp")))
    (is (>= (length segs) 2))
    (is (find :keyword segs :key (lambda (s) (getf (cdr s) :fgcolor))))))

(test test-syntax-highlight-function
  "Contract 9: syntax-highlight colors function calls."
  (let ((segs (passepartout::syntax-highlight "(+ 1 2)" "lisp")))
    (is (>= (length segs) 2))
    (is (find :function segs :key (lambda (s) (getf (cdr s) :fgcolor))))))

(test test-gate-trace-lines-passed
  "Contract 9: gate-trace-lines for passed gate."
  (let ((lines (passepartout::gate-trace-lines
                '((:gate "path" :result :passed)))))
    (is (= 1 (length lines)))
    (is (eq :tool-done (getf (cdar lines) :fgcolor)))))

(test test-gate-trace-lines-blocked
  "Contract 9: gate-trace-lines for blocked gate."
  (let ((lines (passepartout::gate-trace-lines
                '((:gate "shell" :result :blocked :reason "rm")))))
    (is (= 1 (length lines)))
    (is (search "rm" (caar lines)))))

(test test-gate-trace-lines-approval
  "Contract 9: gate-trace-lines for approval gate."
  (let ((lines (passepartout::gate-trace-lines
                '((:gate "network" :result :approval)))))
    (is (= 1 (length lines)))
    (is (search "HITL" (caar lines)))))

(test test-init-state-has-collapsed-gates
  "Contract v0.7.2: init-state includes :collapsed-gates field."
  (passepartout.channel-tui::init-state)
  (let ((cg (passepartout.channel-tui::st :collapsed-gates)))
    (is (null cg))))

(test test-sidebar-state
  "Contract v0.8.0: init-state includes :sidebar-visible (nil) and :sidebar-width (30)."
  (passepartout.channel-tui::init-state)
  (is (null (passepartout.channel-tui::st :sidebar-visible)))
  (is (= 30 (passepartout.channel-tui::st :sidebar-width))))

(test test-sidebar-not-shown-narrow
  "Contract v0.8.0: sidebar is skipped in redraw when terminal width < 120."
  (passepartout.channel-tui::init-state)
  (setf (passepartout.channel-tui::st :sidebar-visible) t)
  ;; Simulating redraw logic: should not invoke view-sidebar when w < 120.
  ;; If view-sidebar were called with a nil fb it would error; this verifies
  ;; the guard in redraw protects the call.
  (let ((fb nil) (w 100) (h 24))
    (is (not (and (passepartout.channel-tui::st :sidebar-visible) (>= w 60))))))

(test test-status-bar-tokens
  "v0.9.0: status bar uses :status-fg and :status-bg theme tokens."
  (is (getf passepartout.channel-tui::*tui-theme* :status-fg))
  (is (getf passepartout.channel-tui::*tui-theme* :status-bg)))