Files
passepartout/org/channel-tui-view.org
Amr Gharbeia d157a837a9 v0.8.0: use cl-tty.box:char-width and cl-tty.markdown:search-highlight
Removed local definitions of char-width (dead code) and search-highlight
(now uses cl-tty.markdown:search-highlight). Moved char-width tests to
cl-tty's box-tests.
2026-05-18 15:50:29 -04:00

30 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 fb w h): no-op. Status bar is a clean black line.
  2. (view-chat fb w h): renders scrolled chat messages. User messages get amber left border (│), agent messages no border, streaming agent gets grey left border. Gate traces/tool calls use ╎ prefix.
  3. (view-input fb w h): renders expanding light grey input box, multi-line word-wrapped prompt, Emacs-style reverse-video cursor, right-aligned lowercase hint at h-2.
  4. (redraw fb w h): wraps view-status/chat/input in begin-sync/end-sync, dispatches per dirty flags, fills global :bg first.
  5. cl-tty.box:char-width for terminal column width. ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8. Used by word-wrap for accurate line counting (v0.7.0).
  6. (sidebar-visible-p w): returns T if sidebar should show given width W and current :sidebar-mode (:auto >120, :visible always, :hidden never).

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 sidebar-visible-p (w)
  "Compute whether sidebar should be shown given terminal width W
and current sidebar mode (:auto/:visible/:hidden)."
  (let ((mode (st :sidebar-mode)))
    (or (eq mode :visible)
        (and (eq mode :auto) (> w 120)))))

(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)
  (declare (ignore fb w h))
  ;; Status bar is now a clean black line — blends with global :bg.
  ;; No clock, no dot, no text. Everything clean.
  )

(defun input-panel-top (chat-w h)
  "Compute the top row of the input panel based on current input buffer."
  (let* ((hpad 2)
         (inner-w (- chat-w (* 2 hpad)))
         (prompt-w (- inner-w 2))
         (text (input-string))
         (lines (word-wrap text prompt-w))
         (n-lines (max 1 (length lines)))
         (panel-rows (max 4 (+ n-lines 2))))
    (- h 4 panel-rows -1)))


;; Build simple tab-like blocks
#+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-view.lisp
(in-package :passepartout.channel-tui)

(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))
          (hpad 2)
          (sidebar-w (if (sidebar-visible-p w) (or (st :sidebar-width) 42) 0))
          (chat-w (- w sidebar-w))
          (msgs (st :messages)) (total (length msgs))
          (panel-top (input-panel-top chat-w h))
          (max-lines (max 0 panel-top)) (is-search (st :search-mode))
          (bordered-w (- chat-w (* 2 hpad) 2))
          (unbordered-w (- chat-w (* 2 hpad)))
          (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 hpad 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))
               (cs (if is-search (cl-tty.markdown:search-highlight content (st :search-query)) content))
               (pairs nil)
               (think-bg (theme-color :thinking-bg))
               (sym-bdr (theme-color :symbolic-border))
               (agent-bdr (theme-color :agent-border))
               (user-bdr (theme-color :user-border))
               (user-fg (theme-color :user-fg))
               (agent-fg (theme-color :agent-fg))
               (system-fg (theme-color :system)))
          (case role
            (:user
             (dolist (l (cl-tty.box:word-wrap cs bordered-w))
               (push (list "│" user-bdr l user-fg) pairs)))
            (             :agent
             (let* ((streaming (getf msg :streaming))
                    (think-rect (if streaming think-bg nil))
                    (bdr (if streaming nil agent-bdr))
                    (bstr (if streaming nil "│"))
                    (wrap-w (if streaming unbordered-w bordered-w))
                    (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 wrap-w)) raw-body)))
               (dolist (l body)
                 (push (list bstr bdr l agent-fg think-rect) pairs))))
            (t (dolist (l (cl-tty.box:word-wrap cs unbordered-w))
                 (push (list nil nil l system-fg) pairs))))
          ;; Gate trace
          (let ((gt (getf msg :gate-trace)))
            (when (and gt (eq role :agent))
              (if (member i (st :collapsed-gates))
                  (push (list "│" sym-bdr (format nil "Gate trace: ~a gates" (length gt)) sym-bdr) pairs)
                  (dolist (entry (passepartout::gate-trace-lines gt))
                    (let ((ec (theme-color (getf (cdr entry) :fgcolor))))
                      (dolist (l (cl-tty.box:word-wrap (car entry) bordered-w))
                        (push (list "│" sym-bdr l ec) pairs)))))))
          ;; Tool calls
          (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)))
                    (push (list "│" (theme-color :tool-done) (format nil "~a … ~,1fs" n d) (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 bordered-w))))
                      (push (list "│" bc (format nil "~a ~a  ~,1fs" pfx name dur) bc) pairs)
                      (dolist (l ol)
                        (push (list "│" bc l bc) 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 ((mh (aref msg-heights i))
                          (spacer (if (< i (1- total)) 1 0)))
                   (if (<= (+ mh spacer) lines-remaining)
                       (progn (decf lines-remaining (+ mh spacer)) (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 panel-top)
                  do (let ((pairs (aref msg-lines i)))
                     (dolist (pair pairs)
                        (when (>= y panel-top) (return))
                         (destructuring-bind (bstr bcolor tstr tcolor &optional rect-bg) pair
                           (when rect-bg
                             (cl-tty.backend:draw-rect fb 0 y 1 1 :bg rect-bg))
                           (let ((has-border (and bstr (> (length bstr) 0))))
                             (when has-border
                               (cl-tty.backend:draw-text fb hpad y bstr bcolor nil))
                             (cl-tty.backend:draw-text fb (+ hpad (if has-border 2 0)) y tstr tcolor nil)))
                        (incf y))
                       ;; spacer between message blocks
                        (when (< i (1- total))
                          (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))
         (hpad 2)
         (sidebar-w (if (sidebar-visible-p w) (or (st :sidebar-width) 42) 0))
         (chat-w (- w sidebar-w))
         (inner-w (- chat-w (* 2 hpad)))
         (prompt-w (- inner-w 2))
         (text (input-string))
         (pos (or (st :cursor-pos) 0))
         (lines (word-wrap text prompt-w))
         (n-lines (max 1 (length lines)))
          (panel-rows (max 4 (+ n-lines 2)))
          (panel-top (input-panel-top chat-w h))
         (bg-i (theme-color :bg-input))
         (input-fg (theme-color :input-fg))
         (hint-fg (theme-color :hint)))
    ;; Fill input panel: panel-top to h-4, indented by hpad
    (cl-tty.backend:draw-rect fb hpad panel-top inner-w panel-rows :bg bg-i)
    ;; Speaker lines for all input rows
    (dotimes (r panel-rows)
      (cl-tty.backend:draw-text fb hpad (+ panel-top r) "│" (theme-color :input-prompt) nil))
    ;; Draw each wrapped input line
    (let ((accum 0) (cursor-line 0) (cursor-col 0))
      (dotimes (i n-lines)
        (let* ((line (nth i lines))
               (row (+ panel-top 1 i))
               (len (length line)))
          (when (>= row (- h 4)) (return))
          (cl-tty.backend:draw-text fb (+ hpad 2) row line input-fg nil)
          (when (and (>= pos accum) (<= pos (+ accum len)))
            (setf cursor-line i
                  cursor-col (- pos accum)))
          (incf accum (1+ len))))
    ;; Hint bar at h-2: F:/MCP: on left, token gauge + keybindings on right
    (let* ((focal (or (st :foveal-id) "-"))
           (focal-str (format nil "F:~a" focal))
           (mcp-str (format nil "MCP:~d" (or (st :mcp-count) 0)))
           (left-str (format nil "~a  ~a" focal-str mcp-str))
           (msg-count (max 1 (length (st :messages))))
           (ctx-est (* msg-count 60))
           (ctx-limit 8192)
           (ctx-pct (min 100 (floor (* 100 ctx-est) ctx-limit)))
           (ctx-tok (if (< ctx-est 1000)
                        (format nil "~d" ctx-est)
                        (format nil "~dK" (floor ctx-est 1000))))
           (ctx-str (format nil "~a (~d%%)" ctx-tok ctx-pct))
           (hint-str "ctrl+p | /help")
           (ctx-fg (cond ((< ctx-pct 50) (theme-color :tool-done))
                         ((< ctx-pct 80) (theme-color :input-prompt))
                         (t (theme-color :error))))
           (hint-x (- chat-w (length hint-str) 2))
           (ctx-x (- hint-x 1 (length ctx-str))))
      (cl-tty.backend:draw-text fb hpad (- h 2) left-str hint-fg (theme-color :bg))
      (cl-tty.backend:draw-text fb ctx-x (- h 2) ctx-str ctx-fg (theme-color :bg))
      (cl-tty.backend:draw-text fb hint-x (- h 2) hint-str hint-fg (theme-color :bg))))))

Sidebar

(defun view-sidebar (fb w h)
  (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) 42)))
         (bg-panel (theme-color :bg-panel))
         (y 0))
    (cl-tty.backend:draw-rect fb x 0 (- w x) (1- h) :bg bg-panel)
    (cl-tty.backend:draw-text fb x (1- h) (make-string (- w x) :initial-element #\Space) nil bg-panel)
    ;; Gate Trace — from latest agent message
    (cl-tty.backend:draw-text fb (+ x 2) (incf y) "GATE TRACE" (theme-color :accent) bg-panel)
    (incf y)
    (let* ((msgs (st :messages))
           (last-gt (loop for i from (1- (length msgs)) downto 0
                          for m = (aref msgs i)
                          when (getf m :gate-trace)
                          return (getf m :gate-trace))))
      (if last-gt
          (dolist (g last-gt)
            (let* ((name (getf g :gate))
                   (result (getf g :result))
                   (reason (getf g :reason))
                   (glyph (case result (:passed "✓") (:blocked "✗") (:approval "→") (t "?")))
                   (color (case result
                            (:passed (theme-color :tool-done))
                            (:blocked (theme-color :error))
                            (:approval (theme-color :input-prompt))
                            (t (theme-color :dim)))))
              (cl-tty.backend:draw-text fb (+ x 2) (incf y) (format nil "  ~a ~a" glyph name) color bg-panel)
              (when reason
                (incf y)
                (cl-tty.backend:draw-text fb (+ x 4) (incf y) reason (theme-color :dim) bg-panel))))
          (cl-tty.backend:draw-text fb (+ x 2) (incf y) "  (none)" (theme-color :dim) bg-panel))
      (incf y 2))
    ;; Rules + Block Count
    (let ((blocked (loop for i below (length (st :messages))
                         for m = (aref (st :messages) i)
                         sum (loop for g in (getf m :gate-trace)
                                   count (eq (getf g :result) :blocked)))))
      (cl-tty.backend:draw-text fb (+ x 2) (incf y) "RULES" (theme-color :accent) bg-panel)
      (incf y)
      (cl-tty.backend:draw-text fb (+ x 2) (incf y)
                               (format nil "  ~d active" (or (st :rule-count) 0))
                               (theme-color :agent-fg) bg-panel)
      (incf y)
      (cl-tty.backend:draw-text fb (+ x 2) (incf y)
                               (format nil "  ~d blocked" blocked)
                               (if (> blocked 0) (theme-color :error) (theme-color :dim)) bg-panel)
      (incf y 2))
    ;; Cost
    (cl-tty.backend:draw-text fb (+ x 2) (incf y) "COST" (theme-color :accent) bg-panel)
    (incf y)
    (cl-tty.backend:draw-text fb (+ x 2) (incf y)
                             (format nil "  $~,2f" (or (st :session-cost) 0.0))
                             (theme-color :status-fg) bg-panel)
    (incf y 2)
    ;; Files (stub)
    (cl-tty.backend:draw-text fb (+ x 2) (incf y) "FILES" (theme-color :accent) bg-panel)
    (incf y)
    (cl-tty.backend:draw-text fb (+ x 2) (incf y) "  (not yet)" (theme-color :dim) bg-panel)
    (incf y 2)
    ;; Version footer
    (let* ((ver (or (st :daemon-version) ""))
           (ver-label (if (> (length ver) 0) (format nil "passepartout ~a" ver) "passepartout"))
           (dot (if (st :connected) "●" "○"))
           (dot-color (if (st :connected) (theme-color :dot-connected) (theme-color :dot-disconnected))))
      (cl-tty.backend:draw-text fb (+ x 2) (- h 2) dot dot-color bg-panel)
      (cl-tty.backend:draw-text fb (+ x 4) (- h 2) ver-label (theme-color :text-muted) bg-panel))))

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))
  (when (or (first (st :dirty)) (second (st :dirty)) (third (st :dirty)))
    (cl-tty.backend:begin-sync fb)
    (cl-tty.backend:draw-rect fb 0 0 w h :bg (theme-color :bg))
    (view-status fb w h)
    (view-chat fb w h)
    (view-input fb w h)
    (when (sidebar-visible-p w)
      (view-sidebar fb w h))
    (cl-tty.backend:end-sync fb)
    (setf (st :dirty) (list nil nil nil))))

(defun position-cursor (fb w h)
  "Draw cursor at the input insertion point using reverse video (Emacs-style).

  The character under the cursor is redrawn with foreground and background
  swapped. If the cursor is past the end of the input string, a reversed
  space is drawn."
  (let* ((sw (if (sidebar-visible-p w) (or (st :sidebar-width) 42) 0))
         (cw (- w sw))
         (hpad 2)
         (text (input-string))
         (text-len (length text))
         (pos (or (st :cursor-pos) 0))
         (prompt-w (- cw (* 2 hpad) 2))
         (display-start (max 0 (- pos (1- prompt-w))))
         (cx (+ hpad 2 (- pos display-start)))
         (cy (- h 6))
         (bg-i (theme-color :bg-input))
         (input-fg (theme-color :input-fg)))
    (if (< pos text-len)
        (let ((ch (char text pos)))
          (cl-tty.backend:draw-text fb cx cy (string ch) bg-i input-fg))
        (cl-tty.backend:draw-text fb cx cy " " bg-i input-fg))
    (finish-output (cl-tty.backend::backend-output-stream fb))))

Implementation — v0.7.0 additions

  • v0.7.1 — Markdown Rendering

render-styled accepts a (text . plist) segment list from the span parser and emits draw-text calls. The w parameter is ignored (layout is line-at-a-time, not fixed-width); theme-color is fully qualified as passepartout.channel-tui:theme-color since this function lives in the passepartout package but the theme API is in passepartout.channel-tui.

The inline span parser (parse-markdown-spans) delegates punctuation delimiters (bold, `code`, italic) to a local pick helper. URLs are handled directly via url-end rather than through pick, so the :url clause was removed from pick's case form to avoid dead code.

(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))))
                              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."
  (declare (ignore w))
  (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 (passepartout.channel-tui:theme-color :accent))
                                      (t (passepartout.channel-tui:theme-color (or (getf attrs :role) :agent-fg))))
                               (passepartout.channel-tui:theme-color :bg)
                               :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-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-mode (:auto) and :sidebar-width (42)."
  (passepartout.channel-tui::init-state)
  (is (eq :auto (passepartout.channel-tui::st :sidebar-mode)))
  (is (= 42 (passepartout.channel-tui::st :sidebar-width))))

(defun sidebar-visible-p (w)
  "Compute whether sidebar should be shown given terminal width W
and current sidebar mode."
  (let ((mode (passepartout.channel-tui::st :sidebar-mode)))
    (or (eq mode :visible)
        (and (eq mode :auto) (> w 120)))))

(test test-sidebar-auto-wide
  "Contract v0.8.0: sidebar auto-shows when terminal > 120 cols."
  (passepartout.channel-tui::init-state)
  (setf (passepartout.channel-tui::st :sidebar-mode) :auto)
  (is (sidebar-visible-p 140))
  (is (not (sidebar-visible-p 100))))

(test test-sidebar-visible-mode
  "Contract v0.8.0: :visible mode shows sidebar regardless of width."
  (passepartout.channel-tui::init-state)
  (setf (passepartout.channel-tui::st :sidebar-mode) :visible)
  (is (sidebar-visible-p 40))
  (is (sidebar-visible-p 140)))

(test test-sidebar-hidden-mode
  "Contract v0.8.0: :hidden mode hides sidebar regardless of width."
  (passepartout.channel-tui::init-state)
  (setf (passepartout.channel-tui::st :sidebar-mode) :hidden)
  (is (not (sidebar-visible-p 140)))
  (is (not (sidebar-visible-p 40))))

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

(test test-new-theme-keys
  "v0.10.0: theme has all zone keys."
  (is (getf passepartout.channel-tui::*tui-theme* :bg))
  (is (getf passepartout.channel-tui::*tui-theme* :bg-panel))
  (is (getf passepartout.channel-tui::*tui-theme* :bg-element))
  (is (getf passepartout.channel-tui::*tui-theme* :bg-input))
  (is (getf passepartout.channel-tui::*tui-theme* :agent-border))
  (is (getf passepartout.channel-tui::*tui-theme* :thinking-bg))
  (is (getf passepartout.channel-tui::*tui-theme* :symbolic-border))
  (is (getf passepartout.channel-tui::*tui-theme* :text-muted)))