From 9492e00318542d8af26e017147517c5a302537c6 Mon Sep 17 00:00:00 2001 From: Amr Gharbeia Date: Wed, 20 May 2026 11:11:00 -0400 Subject: [PATCH] =?UTF-8?q?Phase=204=20=E2=80=94=20sidebar,=20input,=20cha?= =?UTF-8?q?t=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar: replace manual (incf y) tracking with flat list construction. sidebar-lines returns (text . color-key) pairs; view-sidebar loops over them. Version footer stays at h-2. No more fragile y arithmetic. Input panel: use cl-tty.input text-input's render method instead of manual word-wrap + cursor-position computation. Layout node set each frame for dynamic position. Chat: extract msg->pairs (message to renderable lines) and render-pair (draw one line pair) as separate functions. Replace reverse-iteration scroll culling with forward scan that skips by scroll-offset. Same behavior, less nesting. --- org/channel-tui-view.org | 379 ++++++++++++++++++++------------------- 1 file changed, 193 insertions(+), 186 deletions(-) diff --git a/org/channel-tui-view.org b/org/channel-tui-view.org index 8d759f5..58a12bd 100644 --- a/org/channel-tui-view.org +++ b/org/channel-tui-view.org @@ -13,15 +13,22 @@ 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, + multi-line word-wrapped prompt, hint bar at h-2. Text and cursor + rendered by cl-tty.input text-input's render method. +4. (view-sidebar fb w h): renders sidebar panels using ~sidebar-lines~. +5. (sidebar-lines): builds a flat list of (text . color-key) pairs for + the sidebar: gate trace, rules, cost, files, version. +6. (msg->pairs msg index bordered-w unbordered-w is-search): converts + a message to renderable ~(border border-color text text-color &optional bg)~ + lines. Handles markdown, gate trace, tool calls, search highlight. +7. (render-pair fb hpad y pair): draws one message line pair. +8. (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. +9. ~cl-tty.box:char-width~ for terminal column width. ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8. Used by cl-tty.box:word-wrap for accurate line counting. -6. (sidebar-visible-p w): returns T if sidebar should show given width W - and current :sidebar-mode (:auto >120, :visible always, :hidden never). +10. (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 @@ -73,18 +80,91 @@ and current sidebar mode (:auto/:visible/:hidden)." #+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-view.lisp (in-package :passepartout.channel-tui) +(defun msg->pairs (msg index bordered-w unbordered-w is-search) + "Convert a message to a list of (border-str border-color text-str text-color &optional bg) lines." + (let* ((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 index (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 index (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))))))) + (nreverse pairs))) + +(defun render-pair (fb hpad y pair) + "Draw a single (border-str border-color text-str text-color &optional bg) line." + (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 (theme-color :bg))) + (cl-tty.backend:draw-text fb (+ hpad (if has-border 2 0)) y tstr tcolor (theme-color :bg))))) + (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)) + (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)) + ;; Search header (when is-search (let* ((matches (st :search-matches)) (idx (st :search-match-idx)) (query (st :search-query)) @@ -92,93 +172,27 @@ and current sidebar mode (:auto/:visible/:hidden)." (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 (theme-color :bg))) - (cl-tty.backend:draw-text fb (+ hpad (if has-border 2 0)) y tstr tcolor (theme-color :bg)))) - (incf y)) - ;; spacer between message blocks - (when (< i (1- total)) - (incf y))))))))) + ;; Build all message lines once + (let* ((msg-lines (map 'vector + (lambda (msg i) (msg->pairs msg i bordered-w unbordered-w is-search)) + msgs + (make-array total :initial-contents (loop for i below total collect i)))) + (heights (map 'vector #'length msg-lines)) + (scroll-skip (st :scroll-offset)) + (i 0)) + ;; Forward scan: skip messages scrolled past, then render visible ones + (loop while (< i total) + do (let ((hgt (aref heights i))) + (if (> scroll-skip 0) + (decf scroll-skip hgt) + (let ((msg-y y)) + (dolist (pair (aref msg-lines i)) + (when (>= msg-y panel-top) (return)) + (render-pair fb hpad msg-y pair) + (incf msg-y)) + (setf y (1+ msg-y)) ;; +1 spacer between messages + (when (>= y panel-top) (return))))) + (incf i))))) #+END_SRC ** Input Line @@ -192,36 +206,23 @@ and current sidebar mode (:auto/:visible/:hidden)." (inner-w (- chat-w (* 2 hpad))) (prompt-w (- inner-w 2)) (input (st :text-input)) - (text (cl-tty.input:text-input-value input)) - (pos (cl-tty.input:text-input-cursor input)) - (lines (cl-tty.box:word-wrap text prompt-w)) - (n-lines (max 1 (length lines))) + (n-lines (max 1 (length (cl-tty.box:word-wrap (cl-tty.input:text-input-value input) prompt-w)))) (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 (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) (or (< pos (+ accum len)) (= i (1- n-lines)))) - (setf cursor-line i - cursor-col (- pos accum))) - (incf accum (1+ len)))) - ;; Draw block cursor at insertion point - (let* ((cx (+ hpad 2 cursor-col)) - (cy (+ panel-top 1 cursor-line))) - (cl-tty.backend:draw-text fb cx cy "█" :bright-white nil))) + ;; Render text-input widget (word-wrap + cursor) + (let ((ln (make-layout-node))) + (setf (layout-node-x ln) (+ hpad 2) + (layout-node-y ln) (1+ panel-top) + (layout-node-width ln) prompt-w) + (setf (cl-tty.input:text-input-layout-node input) ln) + (cl-tty.box:render input fb)) ;; Hint bar at h-2 (let* ((focal (or (st :foveal-id) "-")) (focal-str (format nil "F:~a" focal)) @@ -248,73 +249,79 @@ and current sidebar mode (:auto/:visible/:hidden)." ** Sidebar #+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-view.lisp +(defun sidebar-lines () + "Collect all sidebar lines as (text . color-key) pairs." + (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))) + (blocked (loop for i below (length msgs) + for m = (aref msgs i) + sum (loop for g in (getf m :gate-trace) + count (eq (getf g :result) :blocked)))) + (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) :dot-connected :dot-disconnected))) + (append + ;; Gate Trace + '("GATE TRACE" . :accent) + (if last-gt + (mapcan (lambda (g) + (let* ((name (getf g :gate)) + (result (getf g :result)) + (reason (getf g :reason)) + (glyph (case result (:passed "✓") (:blocked "✗") (:approval "→") (t "?"))) + (color (case result + (:passed :tool-done) + (:blocked :error) + (:approval :input-prompt) + (t :dim)))) + (if reason + (list (cons (format nil " ~a ~a" glyph name) color) + (cons (format nil " ~a" reason) :dim)) + (list (cons (format nil " ~a ~a" glyph name) color))))) + last-gt) + '((cons " (none)" :dim))) + ;; Rules + '("" nil) + '("RULES" . :accent) + (list (cons (format nil " ~d active" (or (st :rule-count) 0)) :agent-fg)) + (list (cons (format nil " ~d blocked" blocked) + (if (> blocked 0) :error :dim))) + ;; Cost + '("" nil) + '("COST" . :accent) + (list (cons (format nil " $~,2f" (or (st :session-cost) 0.0)) :status-fg)) + ;; Files + '("" nil) + '("FILES" . :accent) + '(" (not yet)" . :dim) + ;; spacer + '("" nil) + ;; Version footer — rendered at h-2, not in the loop + (list (cons (format nil "~a ~a" dot ver-label) dot-color))))) + (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)))) + (lines (sidebar-lines)) + (content-lines (butlast lines)) + (footer-line (car (last lines)))) + (cl-tty.backend:draw-rect fb x 0 (- w x) (1- h) :bg (theme-color :bg-panel)) + (loop for (text . color-key) in content-lines + for y from 0 + when text + do (cl-tty.backend:draw-text fb (+ x 2) y text + (if color-key (theme-color color-key) (theme-color :dim)) + (theme-color :bg-panel))) + ;; Version footer at h-2 + (when footer-line + (cl-tty.backend:draw-text fb (+ x 2) (- h 2) (car footer-line) + (theme-color (cdr footer-line)) + (theme-color :bg-panel))))) #+END_SRC ** Redraw (dirty-flag dispatch)