v0.10.0: TUI visual overhaul — dark-neutral theme, left-border messages, sidebar auto-show, cl-tty style-reset

- Theme: near-black (#0a0a0a) backgrounds, dark-grey panels (#141414),
  warm amber (#fab283) accent only. New keys: :bg, :bg-panel, :bg-element,
  :text-muted. All 13 presets updated.
- Messages: No background fills (sit on global black). User messages get
  amber left border (│). Agent response has no border (invisible).
  Streaming agent messages get grey left border. Gate traces and tool
  calls use grey ╎ prefix. No label lines, no time separators.
- Sidebar: :sidebar-mode with :auto/:visible/:hidden. Auto-shows at >120
  cols (opencode-style). Width 42 with version + connection dot footer.
- Input: 2-char hpad on each side. Grey panel (2 rows: separator +
  prompt). Hint right-aligned at bottom on black.
- Status bar: empty (clean black line).
- cl-tty backend: draw-text, draw-rect, draw-link, draw-border now use
  \e[22;23;24;25;27m (style-only reset) instead of \e[0m (full reset),
  preserving foreground/background across draw calls.
- Fix: all sidebar text draws pass explicit bg-panel background.
- Fix: hint at h-1 passes explicit (theme-color :bg).
- Fix: sidebar bottom row uses draw-text (no \n) to prevent scroll at h-1.
This commit is contained in:
2026-05-16 08:02:53 -04:00
parent 3bc1977632
commit 0a0478f502
3 changed files with 394 additions and 332 deletions

View File

@@ -45,6 +45,13 @@ that the TUI actuator attaches to the response plist before transmission.
#+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-view.lisp
(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."
@@ -59,24 +66,10 @@ Returns a list of strings, one per line."
(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)))
(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.
)
;; v0.7.2: search-highlight — wrap matching text in **bold** for markdown
@@ -99,68 +92,64 @@ Returns a list of strings, one per line."
(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))
(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))
(max-lines (- h 4)) (is-search (st :search-mode)) (y 0))
(max-lines (- h 4)) (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 1 y hdr (theme-color :accent) nil)
(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)) (time (or (getf msg :time) ""))
(content (getf msg :content))
(cs (if is-search (search-highlight content (st :search-query)) content))
(pairs nil))
(pairs nil)
(dim-bg (theme-color :dim))
(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
(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)))
(dolist (l (cl-tty.box:word-wrap cs bordered-w))
(push (list "│" user-bdr l user-fg) 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 #\─)))
(let* ((streaming (getf msg :streaming))
(bdr-color (if streaming dim-bg nil))
(bdr-str (if streaming "│" ""))
(wrap-w (if streaming bordered-w unbordered-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 (- 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))))
(body (mapcan (lambda (l) (cl-tty.box:word-wrap l wrap-w)) raw-body)))
(dolist (l body)
(push (list bdr-str bdr-color l agent-fg) pairs))))
(t (dolist (l (cl-tty.box:word-wrap cs unbordered-w))
(push (list "" 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 (format nil "Gate trace: ~a gates" (length gt))
(theme-color :dim)) pairs)
(push (list "╎" dim-bg (format nil "Gate trace: ~a gates" (length gt)) dim-bg) pairs)
(dolist (entry (passepartout::gate-trace-lines gt))
(push (list (concatenate 'string "╎ " (car entry))
(theme-color (getf (cdr entry) :fgcolor))) pairs)))))
(let ((ec (theme-color (getf (cdr entry) :fgcolor))))
(dolist (l (cl-tty.box:word-wrap (car entry) bordered-w))
(push (list "╎" dim-bg 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))
(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))
(d (or (getf (first tc) :duration) 0.0)))
(push (list "╎" dim-bg (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))
@@ -171,29 +160,10 @@ Returns a list of strings, one per line."
((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)
(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 (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)))))))
(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))
@@ -209,9 +179,11 @@ Returns a list of strings, one per line."
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))
(destructuring-bind (bstr bcolor tstr tcolor) pair
(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)))))))))
#+END_SRC
@@ -220,64 +192,82 @@ Returns a list of strings, one per 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))
(hpad 2)
(sidebar-w (if (sidebar-visible-p w) (or (st :sidebar-width) 42) 0))
(chat-w (- w sidebar-w))
(prompt-w (- chat-w 2)) ; leave room for "> "
(inner-w (- chat-w (* 2 hpad)))
(prompt-w (- inner-w 2))
(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)))
(bg-p (theme-color :bg-panel))
(sep-c (theme-color :separator))
(input-fg (theme-color :input-fg))
(hint-fg (theme-color :hint)))
;; Fill the 2-line input area (separator + prompt) with panel bg, indented by hpad
(cl-tty.backend:draw-rect fb hpad (- h 4) inner-w 2 :bg bg-p)
;; Separator line within the panel
(cl-tty.backend:draw-text fb hpad (- h 4) (make-string inner-w :initial-element #\─) sep-c nil)
;; Input line
(cl-tty.backend:draw-text fb hpad (- h 3) (format nil"> ~a" visible) input-fg nil)
;; Hint line — right-aligned on black background at the very bottom
(let ((hint "Ctrl+P | /help"))
(cl-tty.backend:draw-text fb (- chat-w (length hint) 2) (- h 1) hint hint-fg (theme-color :bg)))))
#+end_src
** Sidebar
#+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-view.lisp
(defun view-sidebar (fb w h)
"Render the right-side sidebar panel with warm colors."
"Render the right-side sidebar panel."
(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)))
(x (- w (or (st :sidebar-width) 42)))
(bg-panel (theme-color :bg-panel))
(y 0))
;; Vertical separator
(dotimes (row h)
(cl-tty.backend:draw-text fb (1- x) row " " nil (theme-color :separator)))
;; Fill sidebar background (h-1 done separately to avoid scroll)
(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)
;; Focus panel
(cl-tty.backend:draw-text fb (1+ x) (incf y) " FOCUS" (theme-color :accent) nil)
(cl-tty.backend:draw-text fb (+ x 2) (incf y) "FOCUS" (theme-color :accent) bg-panel)
(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)
(cl-tty.backend:draw-text fb (+ x 2) (incf y) (format nil " ~a" (or (st :foveal-id) "none"))
(theme-color :agent-fg) bg-panel)
(incf y 2)
;; Rules panel
(cl-tty.backend:draw-text fb (1+ x) (incf y) " RULES" (theme-color :accent) nil)
(cl-tty.backend:draw-text fb (+ x 2) (incf y) "RULES" (theme-color :accent) bg-panel)
(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)
(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 2)
;; Context panel — token gauge
(cl-tty.backend:draw-text fb (1+ x) (incf y) " CONTEXT" (theme-color :accent) nil)
(incf y)
(cl-tty.backend:draw-text fb (+ x 2) (incf y) "CONTEXT" (theme-color :accent) bg-panel)
(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)
(cl-tty.backend:draw-text fb (+ x 2) (incf y)
(format nil " [~a~a]" bar
(make-string (- 10 bar-len) :initial-element #\Space))
(theme-color :dim) nil)
(theme-color :dim) bg-panel)
(incf y)
(cl-tty.backend:draw-text fb (1+ x) (incf y) (format nil " ~d%" pct)
(theme-color :status-fg) nil)
(cl-tty.backend:draw-text fb (+ x 2) (incf y) (format nil " ~d%" pct)
(theme-color :status-fg) bg-panel)
(incf y 2))
;; MCP panel
(cl-tty.backend:draw-text fb (1+ x) (incf y) " MCP" (theme-color :accent) nil)
(cl-tty.backend:draw-text fb (+ x 2) (incf y) "MCP" (theme-color :accent) bg-panel)
(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)))
(cl-tty.backend:draw-text fb (+ x 2) (incf y) (format nil " ~d server~:p" (or (st :mcp-count) 0))
(theme-color :agent-fg) bg-panel)
;; Version footer at bottom with connection dot
(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))))
#+END_SRC
** Redraw (dirty-flag dispatch)
@@ -286,10 +276,12 @@ Returns a list of strings, one per line."
(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)
;; Fill global background
(cl-tty.backend:draw-rect fb 0 0 w h :bg (theme-color :bg))
(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))
(when (sidebar-visible-p w)
(view-sidebar fb w h))
(setf (st :dirty) (list nil nil nil))))
#+END_SRC
@@ -597,23 +589,48 @@ dead code.
(is (null cg))))
(test test-sidebar-state
"Contract v0.8.0: init-state includes :sidebar-visible (nil) and :sidebar-width (30)."
"Contract v0.8.0: init-state includes :sidebar-mode (:auto) and :sidebar-width (42)."
(passepartout.channel-tui::init-state)
(is (null (passepartout.channel-tui::st :sidebar-visible)))
(is (= 30 (passepartout.channel-tui::st :sidebar-width))))
(is (eq :auto (passepartout.channel-tui::st :sidebar-mode)))
(is (= 42 (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."
(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-visible) t)
;; Redraw guard: view-sidebar is only called when w >= 60. This
;; verifies the guard expression evaluates to nil at w=100 when
;; sidebar-visible is set but width is below 120 threshold.
(let ((w 100))
(is (not (and (passepartout.channel-tui::st :sidebar-visible) (>= w 60))))))
(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 new :bg, :bg-panel, :bg-element, :text-muted 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* :text-muted)))
#+END_SRC