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

@@ -45,20 +45,57 @@ Event handlers + daemon I/O + main loop.
(or name raw))
raw)))
(cond
;; v0.7.1: Esc — interrupt streaming
((and (eql ch 27) (st :streaming-text))
(send-daemon (list :type :event :payload '(:action :cancel-stream)))
(when (> (length (st :messages)) 0)
(let ((idx (1- (length (st :messages)))))
(setf (getf (aref (st :messages) idx) :content)
(concatenate 'string
(getf (aref (st :messages) idx) :content)
" [interrupted]"))
(setf (getf (aref (st :messages) idx) :streaming) nil)
(setf (getf (aref (st :messages) idx) :time) (now))))
(setf (st :streaming-text) nil)
(setf (st :busy) nil)
(setf (st :dirty) (list t t nil)))
;; v0.7.1: Esc — interrupt streaming
((and (eql ch 27) (st :streaming-text))
(send-daemon (list :type :event :payload '(:action :cancel-stream)))
(when (> (length (st :messages)) 0)
(let ((idx (1- (length (st :messages)))))
(setf (getf (aref (st :messages) idx) :content)
(concatenate 'string
(getf (aref (st :messages) idx) :content)
" [interrupted]"))
(setf (getf (aref (st :messages) idx) :streaming) nil)
(setf (getf (aref (st :messages) idx) :time) (now))))
(setf (st :streaming-text) nil)
(setf (st :busy) 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
((and (or (eql ch 9) (eq ch :tab))
(null (st :input-buffer)))
@@ -256,33 +293,27 @@ Event handlers + daemon I/O + main loop.
;; /context dropped — pruned nodes
((string-equal text "/context dropped")
(add-msg :system "Context debugging: dropped nodes view not yet available (v0.8.0)"))
;; /search command — message search
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search "))
(let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8))))
(msgs (st :messages))
(total (length msgs))
(matches nil))
(loop for i from 0 below total
for m = (aref msgs i)
for content = (getf m :content)
when (search query (string-downcase content))
do (push (list i content) matches))
(setf matches (nreverse matches))
(if matches
(progn
(add-msg :system (format nil "Found ~d matches for '~a':"
(length matches) query))
(dolist (match matches)
(let* ((idx (first match))
(content (second match))
(pos (search query (string-downcase content)))
(preview (if (> (length content) 60)
(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)))))
;; /search command — message search
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search "))
(let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8))))
(msgs (st :messages))
(total (length msgs))
(matches nil))
(loop for i from 0 below total
for m = (aref msgs i)
for content = (getf m :content)
when (search query (string-downcase content))
do (push i matches))
(setf matches (nreverse matches))
;; Enter search mode
(setf (st :search-mode) t
(st :search-query) query
(st :search-matches) matches
(st :search-match-idx) 0)
(if matches
(add-msg :system (format nil "Search: ~d matches for '~a' (1/~d) — Up/Down nav, Enter jump, Esc exit"
(length matches) query (length matches)))
(add-msg :system (format nil "0 matches for '~a'" query)))))
;; /rewind command — session rewind
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/rewind "))
(let* ((n-str (string-trim '(#\Space) (subseq text 8)))
@@ -1174,4 +1205,46 @@ Event handlers + daemon I/O + main loop.
(on-key 7)
(let ((m (aref (st :messages) 0)))
(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