v0.7.2: message search mode — navigate, highlight, jump — TDD

Search mode activated by /search <query>. State fields: :search-mode,
:search-query, :search-matches, :search-match-idx. Up/Down arrows
navigate between matches, Enter jumps to current match, Escape exits.

search-highlight wraps matching substrings in **bold** for markdown
rendering. View-chat shows search header bar with match count and
current position.

- channel-tui-state: 4 search state fields in init-state
- channel-tui-main: modified /search handler, search-mode key handlers
  (Up/Down/Enter/Escape), 3 new tests (activate, escape, nav)
- channel-tui-view: search-highlight fn, search header bar,
  highlighted content in count+render loops
- TUI Main: 97/98 (1 pre-existing flake)  View: 29/29
This commit is contained in:
2026-05-08 21:02:45 -04:00
parent 93a38d5308
commit 06aff97b4e
6 changed files with 312 additions and 149 deletions

View File

@@ -11,20 +11,57 @@
(or name raw)) (or name raw))
raw))) raw)))
(cond (cond
;; v0.7.1: Esc — interrupt streaming ;; v0.7.1: Esc — interrupt streaming
((and (eql ch 27) (st :streaming-text)) ((and (eql ch 27) (st :streaming-text))
(send-daemon (list :type :event :payload '(:action :cancel-stream))) (send-daemon (list :type :event :payload '(:action :cancel-stream)))
(when (> (length (st :messages)) 0) (when (> (length (st :messages)) 0)
(let ((idx (1- (length (st :messages))))) (let ((idx (1- (length (st :messages)))))
(setf (getf (aref (st :messages) idx) :content) (setf (getf (aref (st :messages) idx) :content)
(concatenate 'string (concatenate 'string
(getf (aref (st :messages) idx) :content) (getf (aref (st :messages) idx) :content)
" [interrupted]")) " [interrupted]"))
(setf (getf (aref (st :messages) idx) :streaming) nil) (setf (getf (aref (st :messages) idx) :streaming) nil)
(setf (getf (aref (st :messages) idx) :time) (now)))) (setf (getf (aref (st :messages) idx) :time) (now))))
(setf (st :streaming-text) nil) (setf (st :streaming-text) nil)
(setf (st :busy) nil) (setf (st :busy) nil)
(setf (st :dirty) (list t t nil))) (setf (st :dirty) (list t t nil)))
;; v0.7.2: Esc — exit search mode
((and (eql ch 27) (st :search-mode))
(setf (st :search-mode) nil
(st :search-matches) nil
(st :search-query) "")
(setf (st :dirty) (list nil t nil))
(add-msg :system "Search exited"))
;; v0.7.2: search mode — Up/Down navigate matches
((and (st :search-mode) (or (eql ch 259) (eq ch :up)))
(let* ((matches (st :search-matches))
(idx (st :search-match-idx))
(new-idx (max 0 (1- idx))))
(setf (st :search-match-idx) new-idx)
(when matches
(setf (st :scroll-offset) (nth new-idx matches))
(add-msg :system (format nil "Match ~d/~d" (1+ new-idx) (length matches)))
(setf (st :dirty) (list nil t nil)))))
((and (st :search-mode) (or (eql ch 258) (eq ch :down)))
(let* ((matches (st :search-matches))
(idx (st :search-match-idx))
(new-idx (min (1- (length matches)) (1+ idx))))
(setf (st :search-match-idx) new-idx)
(when matches
(setf (st :scroll-offset) (nth new-idx matches))
(add-msg :system (format nil "Match ~d/~d" (1+ new-idx) (length matches)))
(setf (st :dirty) (list nil t nil)))))
;; v0.7.2: search mode — Enter jumps to current match
((and (st :search-mode) (or (eql ch 13) (eql ch 10) (eq ch :enter)))
(let ((matches (st :search-matches))
(idx (st :search-match-idx)))
(when (and matches (>= (length matches) (1+ idx)))
(setf (st :scroll-offset) (nth idx matches))
(setf (st :search-mode) nil
(st :search-matches) nil
(st :search-query) "")
(add-msg :system (format nil "Jumped to match ~d" (1+ idx)))
(setf (st :dirty) (list nil t nil)))))
;; v0.7.1: Tab on empty input — extract then open URL from agent message ;; v0.7.1: Tab on empty input — extract then open URL from agent message
((and (or (eql ch 9) (eq ch :tab)) ((and (or (eql ch 9) (eq ch :tab))
(null (st :input-buffer))) (null (st :input-buffer)))
@@ -222,33 +259,27 @@
;; /context dropped — pruned nodes ;; /context dropped — pruned nodes
((string-equal text "/context dropped") ((string-equal text "/context dropped")
(add-msg :system "Context debugging: dropped nodes view not yet available (v0.8.0)")) (add-msg :system "Context debugging: dropped nodes view not yet available (v0.8.0)"))
;; /search command — message search ;; /search command — message search
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search ")) ((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search "))
(let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8)))) (let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8))))
(msgs (st :messages)) (msgs (st :messages))
(total (length msgs)) (total (length msgs))
(matches nil)) (matches nil))
(loop for i from 0 below total (loop for i from 0 below total
for m = (aref msgs i) for m = (aref msgs i)
for content = (getf m :content) for content = (getf m :content)
when (search query (string-downcase content)) when (search query (string-downcase content))
do (push (list i content) matches)) do (push i matches))
(setf matches (nreverse matches)) (setf matches (nreverse matches))
(if matches ;; Enter search mode
(progn (setf (st :search-mode) t
(add-msg :system (format nil "Found ~d matches for '~a':" (st :search-query) query
(length matches) query)) (st :search-matches) matches
(dolist (match matches) (st :search-match-idx) 0)
(let* ((idx (first match)) (if matches
(content (second match)) (add-msg :system (format nil "Search: ~d matches for '~a' (1/~d) — Up/Down nav, Enter jump, Esc exit"
(pos (search query (string-downcase content))) (length matches) query (length matches)))
(preview (if (> (length content) 60) (add-msg :system (format nil "0 matches for '~a'" query)))))
(concatenate 'string
(subseq content (max 0 (- pos 20)) (min (length content) (+ pos 40)))
"...")
content)))
(add-msg :system (format nil " #~d: ...~a..." idx preview)))))
(add-msg :system (format nil "No matches for '~a'" query)))))
;; /rewind command — session rewind ;; /rewind command — session rewind
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/rewind ")) ((and (>= (length text) 8) (string-equal (subseq text 0 8) "/rewind "))
(let* ((n-str (string-trim '(#\Space) (subseq text 8))) (let* ((n-str (string-trim '(#\Space) (subseq text 8)))
@@ -1127,3 +1158,45 @@
(on-key 7) (on-key 7)
(let ((m (aref (st :messages) 0))) (let ((m (aref (st :messages) 0)))
(fiveam:is (search "No gate trace" (getf m :content))))) (fiveam:is (search "No gate trace" (getf m :content)))))
;; ── v0.7.2 Message Search Mode ──
(fiveam:test test-search-mode-activate
"Contract v0.7.2: /search enters search mode."
(init-state)
(add-msg :agent "hello world")
(add-msg :agent "goodbye")
(dolist (ch (coerce "/search hello" 'list))
(on-key (char-code ch)))
(on-key 13)
(fiveam:is (eq t (st :search-mode)))
(fiveam:is (string= "hello" (st :search-query)))
(fiveam:is (= 1 (length (st :search-matches)))))
(fiveam:test test-search-mode-escape-exits
"Contract v0.7.2: Escape exits search mode."
(init-state)
(add-msg :agent "test")
(dolist (ch (coerce "/search test" 'list))
(on-key (char-code ch)))
(on-key 13)
(fiveam:is (eq t (st :search-mode)))
(on-key 27) ;; Escape
(fiveam:is (null (st :search-mode))))
(fiveam:test test-search-mode-up-down-nav
"Contract v0.7.2: Up/Down navigates between search matches."
(init-state)
(add-msg :agent "aaa hello bbb")
(add-msg :agent "ccc hello ddd")
(add-msg :agent "no match here")
(dolist (ch (coerce "/search hello" 'list))
(on-key (char-code ch)))
(on-key 13)
(fiveam:is (= 0 (st :search-match-idx)))
(on-key 258) ;; Down
(fiveam:is (= 1 (st :search-match-idx)))
(on-key 259) ;; Up
(fiveam:is (= 0 (st :search-match-idx)))
(on-key 259) ;; Up (clamped)
(fiveam:is (= 0 (st :search-match-idx))))

View File

@@ -116,7 +116,9 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
:pending-ctrl-x nil :pending-ctrl-x nil
:scroll-at-bottom t :scroll-notify nil :scroll-at-bottom t :scroll-notify nil
:streaming-text nil :url-buffer nil ; v0.7.1 :streaming-text nil :url-buffer nil ; v0.7.1
:collapsed-gates nil ; v0.7.2 :collapsed-gates nil ; v0.7.2
:search-mode nil :search-query "" ; v0.7.2
:search-matches nil :search-match-idx 0
:dirty (list nil nil nil)))) :dirty (list nil nil nil))))
(defun now () (defun now ()

View File

@@ -23,31 +23,22 @@
:fgcolor (theme-color :timestamp)) :fgcolor (theme-color :timestamp))
(refresh win)) (refresh win))
(defun word-wrap (text width) ;; v0.7.2: search-highlight — wrap matching text in **bold** for markdown
"Break text into lines at word boundaries, each <= width chars. (defun search-highlight (content query)
Returns list of trimmed strings. Single words wider than width are split." "Wrap occurrences of QUERY in CONTENT with **bold** markers."
(let ((lines '()) (let ((lower-content (string-downcase content))
(pos 0) (lower-query (string-downcase query))
(len (length text))) (result "") (pos 0))
(loop while (< pos len) (when (and query (> (length query) 0))
do (let ((end (min len (+ pos width)))) (loop
(cond (let ((found (search lower-query lower-content :start2 pos)))
((>= end len) (unless found (return))
(push (string-trim '(#\Space) (subseq text pos len)) lines) (setf result (concatenate 'string result
(setf pos len)) (subseq content pos found)
((char= (char text (1- end)) #\Space) "**" (subseq content found (+ found (length query))) "**"))
(push (string-trim '(#\Space) (subseq text pos end)) lines) (setf pos (+ found (length query)))))
(setf pos end)) (setf result (concatenate 'string result (subseq content pos)))
(t (if (string= result "") content result))))
(let ((last-space (position #\Space text :from-end t :end (1+ end) :start pos)))
(if (and last-space (> last-space pos))
(progn
(push (string-trim '(#\Space) (subseq text pos last-space)) lines)
(setf pos (1+ last-space)))
(progn
(push (string-trim '(#\Space) (subseq text pos end)) lines)
(setf pos end))))))))
(nreverse lines)))
(defun view-chat (win h) (defun view-chat (win h)
(clear win) (clear win)
@@ -56,18 +47,32 @@ Returns list of trimmed strings. Single words wider than width are split."
(msgs (st :messages)) (msgs (st :messages))
(total (length msgs)) (total (length msgs))
(max-lines (- h 2)) (max-lines (- h 2))
(is-search (st :search-mode))
(y 1)) (y 1))
;; v0.7.2: search mode header
(when is-search
(let* ((matches (st :search-matches))
(idx (st :search-match-idx))
(query (st :search-query))
(header (format nil "Search: ~d matches for '~a' (~d/~d) — Esc to exit"
(length matches) query (1+ idx) (length matches))))
(add-string win header :y y :x 1 :n (1- w) :fgcolor (theme-color :highlight))
(incf y)
(decf max-lines)))
;; Count visible messages from end, accounting for word wrap ;; Count visible messages from end, accounting for word wrap
(let* ((msg-count 0) (let* ((msg-count 0)
(lines-remaining max-lines)) (lines-remaining max-lines))
(loop for i from (1- total) downto 0 (loop for i from (1- total) downto 0
while (> lines-remaining 0) while (> lines-remaining 0)
do (let* ((msg (aref msgs i)) do (let* ((msg (aref msgs i))
(role (getf msg :role)) (role (getf msg :role))
(content (getf msg :content)) (content (getf msg :content))
(time (or (getf msg :time) "")) (time (or (getf msg :time) ""))
(prefix (case role (:user "⬆") (:agent "⬇") (t " "))) (prefix (case role (:user "⬆") (:agent "⬇") (t " ")))
(line-text (format nil "~a [~a] ~a" prefix time content)) (content-show (if is-search
(search-highlight content (st :search-query))
content))
(line-text (format nil "~a [~a] ~a" prefix time content-show))
(wrapped (word-wrap line-text (- w 2))) (wrapped (word-wrap line-text (- w 2)))
(nlines (length wrapped))) (nlines (length wrapped)))
(if (<= nlines lines-remaining) (if (<= nlines lines-remaining)
@@ -86,7 +91,10 @@ Returns list of trimmed strings. Single words wider than width are split."
(prefix (case role (:user "⬆") (:agent "⬇") (t " "))) (prefix (case role (:user "⬆") (:agent "⬇") (t " ")))
(is-panel (getf msg :panel)) (is-panel (getf msg :panel))
(is-resolved (getf msg :panel-resolved)) (is-resolved (getf msg :panel-resolved))
(line-text (format nil "~a [~a] ~a" prefix time content)) (content-show (if is-search
(search-highlight content (st :search-query))
content))
(line-text (format nil "~a [~a] ~a" prefix time content-show))
(wrapped (word-wrap line-text (- w 2)))) (wrapped (word-wrap line-text (- w 2))))
;; HITL panel: render with colored border ;; HITL panel: render with colored border
(when is-panel (when is-panel

View File

@@ -45,20 +45,57 @@ Event handlers + daemon I/O + main loop.
(or name raw)) (or name raw))
raw))) raw)))
(cond (cond
;; v0.7.1: Esc — interrupt streaming ;; v0.7.1: Esc — interrupt streaming
((and (eql ch 27) (st :streaming-text)) ((and (eql ch 27) (st :streaming-text))
(send-daemon (list :type :event :payload '(:action :cancel-stream))) (send-daemon (list :type :event :payload '(:action :cancel-stream)))
(when (> (length (st :messages)) 0) (when (> (length (st :messages)) 0)
(let ((idx (1- (length (st :messages))))) (let ((idx (1- (length (st :messages)))))
(setf (getf (aref (st :messages) idx) :content) (setf (getf (aref (st :messages) idx) :content)
(concatenate 'string (concatenate 'string
(getf (aref (st :messages) idx) :content) (getf (aref (st :messages) idx) :content)
" [interrupted]")) " [interrupted]"))
(setf (getf (aref (st :messages) idx) :streaming) nil) (setf (getf (aref (st :messages) idx) :streaming) nil)
(setf (getf (aref (st :messages) idx) :time) (now)))) (setf (getf (aref (st :messages) idx) :time) (now))))
(setf (st :streaming-text) nil) (setf (st :streaming-text) nil)
(setf (st :busy) nil) (setf (st :busy) nil)
(setf (st :dirty) (list t t nil))) (setf (st :dirty) (list t t nil)))
;; v0.7.2: Esc — exit search mode
((and (eql ch 27) (st :search-mode))
(setf (st :search-mode) nil
(st :search-matches) nil
(st :search-query) "")
(setf (st :dirty) (list nil t nil))
(add-msg :system "Search exited"))
;; v0.7.2: search mode — Up/Down navigate matches
((and (st :search-mode) (or (eql ch 259) (eq ch :up)))
(let* ((matches (st :search-matches))
(idx (st :search-match-idx))
(new-idx (max 0 (1- idx))))
(setf (st :search-match-idx) new-idx)
(when matches
(setf (st :scroll-offset) (nth new-idx matches))
(add-msg :system (format nil "Match ~d/~d" (1+ new-idx) (length matches)))
(setf (st :dirty) (list nil t nil)))))
((and (st :search-mode) (or (eql ch 258) (eq ch :down)))
(let* ((matches (st :search-matches))
(idx (st :search-match-idx))
(new-idx (min (1- (length matches)) (1+ idx))))
(setf (st :search-match-idx) new-idx)
(when matches
(setf (st :scroll-offset) (nth new-idx matches))
(add-msg :system (format nil "Match ~d/~d" (1+ new-idx) (length matches)))
(setf (st :dirty) (list nil t nil)))))
;; v0.7.2: search mode — Enter jumps to current match
((and (st :search-mode) (or (eql ch 13) (eql ch 10) (eq ch :enter)))
(let ((matches (st :search-matches))
(idx (st :search-match-idx)))
(when (and matches (>= (length matches) (1+ idx)))
(setf (st :scroll-offset) (nth idx matches))
(setf (st :search-mode) nil
(st :search-matches) nil
(st :search-query) "")
(add-msg :system (format nil "Jumped to match ~d" (1+ idx)))
(setf (st :dirty) (list nil t nil)))))
;; v0.7.1: Tab on empty input — extract then open URL from agent message ;; v0.7.1: Tab on empty input — extract then open URL from agent message
((and (or (eql ch 9) (eq ch :tab)) ((and (or (eql ch 9) (eq ch :tab))
(null (st :input-buffer))) (null (st :input-buffer)))
@@ -256,33 +293,27 @@ Event handlers + daemon I/O + main loop.
;; /context dropped — pruned nodes ;; /context dropped — pruned nodes
((string-equal text "/context dropped") ((string-equal text "/context dropped")
(add-msg :system "Context debugging: dropped nodes view not yet available (v0.8.0)")) (add-msg :system "Context debugging: dropped nodes view not yet available (v0.8.0)"))
;; /search command — message search ;; /search command — message search
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search ")) ((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search "))
(let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8)))) (let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8))))
(msgs (st :messages)) (msgs (st :messages))
(total (length msgs)) (total (length msgs))
(matches nil)) (matches nil))
(loop for i from 0 below total (loop for i from 0 below total
for m = (aref msgs i) for m = (aref msgs i)
for content = (getf m :content) for content = (getf m :content)
when (search query (string-downcase content)) when (search query (string-downcase content))
do (push (list i content) matches)) do (push i matches))
(setf matches (nreverse matches)) (setf matches (nreverse matches))
(if matches ;; Enter search mode
(progn (setf (st :search-mode) t
(add-msg :system (format nil "Found ~d matches for '~a':" (st :search-query) query
(length matches) query)) (st :search-matches) matches
(dolist (match matches) (st :search-match-idx) 0)
(let* ((idx (first match)) (if matches
(content (second match)) (add-msg :system (format nil "Search: ~d matches for '~a' (1/~d) — Up/Down nav, Enter jump, Esc exit"
(pos (search query (string-downcase content))) (length matches) query (length matches)))
(preview (if (> (length content) 60) (add-msg :system (format nil "0 matches for '~a'" query)))))
(concatenate 'string
(subseq content (max 0 (- pos 20)) (min (length content) (+ pos 40)))
"...")
content)))
(add-msg :system (format nil " #~d: ...~a..." idx preview)))))
(add-msg :system (format nil "No matches for '~a'" query)))))
;; /rewind command — session rewind ;; /rewind command — session rewind
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/rewind ")) ((and (>= (length text) 8) (string-equal (subseq text 0 8) "/rewind "))
(let* ((n-str (string-trim '(#\Space) (subseq text 8))) (let* ((n-str (string-trim '(#\Space) (subseq text 8)))
@@ -1174,4 +1205,46 @@ Event handlers + daemon I/O + main loop.
(on-key 7) (on-key 7)
(let ((m (aref (st :messages) 0))) (let ((m (aref (st :messages) 0)))
(fiveam:is (search "No gate trace" (getf m :content))))) (fiveam:is (search "No gate trace" (getf m :content)))))
;; ── v0.7.2 Message Search Mode ──
(fiveam:test test-search-mode-activate
"Contract v0.7.2: /search enters search mode."
(init-state)
(add-msg :agent "hello world")
(add-msg :agent "goodbye")
(dolist (ch (coerce "/search hello" 'list))
(on-key (char-code ch)))
(on-key 13)
(fiveam:is (eq t (st :search-mode)))
(fiveam:is (string= "hello" (st :search-query)))
(fiveam:is (= 1 (length (st :search-matches)))))
(fiveam:test test-search-mode-escape-exits
"Contract v0.7.2: Escape exits search mode."
(init-state)
(add-msg :agent "test")
(dolist (ch (coerce "/search test" 'list))
(on-key (char-code ch)))
(on-key 13)
(fiveam:is (eq t (st :search-mode)))
(on-key 27) ;; Escape
(fiveam:is (null (st :search-mode))))
(fiveam:test test-search-mode-up-down-nav
"Contract v0.7.2: Up/Down navigates between search matches."
(init-state)
(add-msg :agent "aaa hello bbb")
(add-msg :agent "ccc hello ddd")
(add-msg :agent "no match here")
(dolist (ch (coerce "/search hello" 'list))
(on-key (char-code ch)))
(on-key 13)
(fiveam:is (= 0 (st :search-match-idx)))
(on-key 258) ;; Down
(fiveam:is (= 1 (st :search-match-idx)))
(on-key 259) ;; Up
(fiveam:is (= 0 (st :search-match-idx)))
(on-key 259) ;; Up (clamped)
(fiveam:is (= 0 (st :search-match-idx))))
#+end_src #+end_src

View File

@@ -136,7 +136,9 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
:pending-ctrl-x nil :pending-ctrl-x nil
:scroll-at-bottom t :scroll-notify nil :scroll-at-bottom t :scroll-notify nil
:streaming-text nil :url-buffer nil ; v0.7.1 :streaming-text nil :url-buffer nil ; v0.7.1
:collapsed-gates nil ; v0.7.2 :collapsed-gates nil ; v0.7.2
:search-mode nil :search-query "" ; v0.7.2
:search-matches nil :search-match-idx 0
:dirty (list nil nil nil)))) :dirty (list nil nil nil))))
#+end_src #+end_src

View File

@@ -67,35 +67,23 @@ that the TUI actuator attaches to the response plist before transmission.
:y 2 :x (max 1 (- (width win) 12)) :y 2 :x (max 1 (- (width win) 12))
:fgcolor (theme-color :timestamp)) :fgcolor (theme-color :timestamp))
(refresh win)) (refresh win))
#+end_src
** Chat Area ;; v0.7.2: search-highlight — wrap matching text in **bold** for markdown
#+begin_src lisp (defun search-highlight (content query)
(defun word-wrap (text width) "Wrap occurrences of QUERY in CONTENT with **bold** markers."
"Break text into lines at word boundaries, each <= width chars. (let ((lower-content (string-downcase content))
Returns list of trimmed strings. Single words wider than width are split." (lower-query (string-downcase query))
(let ((lines '()) (result "") (pos 0))
(pos 0) (when (and query (> (length query) 0))
(len (length text))) (loop
(loop while (< pos len) (let ((found (search lower-query lower-content :start2 pos)))
do (let ((end (min len (+ pos width)))) (unless found (return))
(cond (setf result (concatenate 'string result
((>= end len) (subseq content pos found)
(push (string-trim '(#\Space) (subseq text pos len)) lines) "**" (subseq content found (+ found (length query))) "**"))
(setf pos len)) (setf pos (+ found (length query)))))
((char= (char text (1- end)) #\Space) (setf result (concatenate 'string result (subseq content pos)))
(push (string-trim '(#\Space) (subseq text pos end)) lines) (if (string= result "") content result))))
(setf pos end))
(t
(let ((last-space (position #\Space text :from-end t :end (1+ end) :start pos)))
(if (and last-space (> last-space pos))
(progn
(push (string-trim '(#\Space) (subseq text pos last-space)) lines)
(setf pos (1+ last-space)))
(progn
(push (string-trim '(#\Space) (subseq text pos end)) lines)
(setf pos end))))))))
(nreverse lines)))
(defun view-chat (win h) (defun view-chat (win h)
(clear win) (clear win)
@@ -104,18 +92,32 @@ Returns list of trimmed strings. Single words wider than width are split."
(msgs (st :messages)) (msgs (st :messages))
(total (length msgs)) (total (length msgs))
(max-lines (- h 2)) (max-lines (- h 2))
(is-search (st :search-mode))
(y 1)) (y 1))
;; v0.7.2: search mode header
(when is-search
(let* ((matches (st :search-matches))
(idx (st :search-match-idx))
(query (st :search-query))
(header (format nil "Search: ~d matches for '~a' (~d/~d) — Esc to exit"
(length matches) query (1+ idx) (length matches))))
(add-string win header :y y :x 1 :n (1- w) :fgcolor (theme-color :highlight))
(incf y)
(decf max-lines)))
;; Count visible messages from end, accounting for word wrap ;; Count visible messages from end, accounting for word wrap
(let* ((msg-count 0) (let* ((msg-count 0)
(lines-remaining max-lines)) (lines-remaining max-lines))
(loop for i from (1- total) downto 0 (loop for i from (1- total) downto 0
while (> lines-remaining 0) while (> lines-remaining 0)
do (let* ((msg (aref msgs i)) do (let* ((msg (aref msgs i))
(role (getf msg :role)) (role (getf msg :role))
(content (getf msg :content)) (content (getf msg :content))
(time (or (getf msg :time) "")) (time (or (getf msg :time) ""))
(prefix (case role (:user "⬆") (:agent "⬇") (t " "))) (prefix (case role (:user "⬆") (:agent "⬇") (t " ")))
(line-text (format nil "~a [~a] ~a" prefix time content)) (content-show (if is-search
(search-highlight content (st :search-query))
content))
(line-text (format nil "~a [~a] ~a" prefix time content-show))
(wrapped (word-wrap line-text (- w 2))) (wrapped (word-wrap line-text (- w 2)))
(nlines (length wrapped))) (nlines (length wrapped)))
(if (<= nlines lines-remaining) (if (<= nlines lines-remaining)
@@ -134,7 +136,10 @@ Returns list of trimmed strings. Single words wider than width are split."
(prefix (case role (:user "⬆") (:agent "⬇") (t " "))) (prefix (case role (:user "⬆") (:agent "⬇") (t " ")))
(is-panel (getf msg :panel)) (is-panel (getf msg :panel))
(is-resolved (getf msg :panel-resolved)) (is-resolved (getf msg :panel-resolved))
(line-text (format nil "~a [~a] ~a" prefix time content)) (content-show (if is-search
(search-highlight content (st :search-query))
content))
(line-text (format nil "~a [~a] ~a" prefix time content-show))
(wrapped (word-wrap line-text (- w 2)))) (wrapped (word-wrap line-text (- w 2))))
;; HITL panel: render with colored border ;; HITL panel: render with colored border
(when is-panel (when is-panel