fixes for @ names

ober

5c041b61b2d108879ba29e14c97dd4148956b769

diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 7273fd4..616a3c0 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -49,7 +49,8 @@
     (account version receive-mode width height input status quit? event-count
      conversations selected-index mode picker-query picker-index removed aliases
      logdb live-capture? resend challenge rate-limits send-events
-     capture-events capture-stop-box capture-worker))
+     capture-events capture-stop-box capture-worker
+     thread-scroll mention-active? mention-index mention-candidates))
 
   ;; Open the encrypted message log. Passphrase comes from JERBOA_SIGNAL_DB_KEY
   ;; if set (enables headless use), otherwise we prompt. A blank passphrase or a
@@ -204,8 +205,12 @@
                     (make-hashtable equal-hash equal?)
                     (make-channel/buf 64)
                     (make-channel/buf 256)
-                    (box #f)
-                    #f))
+                     (box #f)
+                     #f
+                     0
+                     #f
+                     0
+                     '()))
 
   ;; Pull recent rows out of the encrypted log so search and scrollback cover
   ;; previous sessions, not just this one. Runs after contact/group seeding so
@@ -808,6 +813,10 @@
         (conversation-typing-set! conv #f))
       (unless (selected-conversation? state id)
         (conversation-unread-set! conv (+ (conversation-unread conv) 1)))
+      ;; Reset scroll to newest messages when we receive a message in the
+      ;; currently visible conversation.
+      (when (selected-conversation? state id)
+        (tui-state-thread-scroll-set! state 0))
       (tui-state-status-set! state "New Signal message received.")))
 
   ;; Match the receipt's timestamps against our outbound messages in every
@@ -1023,6 +1032,8 @@
                [conv (list-ref conversations next-index)])
           (tui-state-selected-index-set! state next-index)
           (conversation-unread-set! conv 0)
+          (tui-state-thread-scroll-set! state 0)
+          (cancel-mention! state)
           (tui-state-status-set!
             state
             (string-append "Selected " (conversation-title conv) "."))))))
@@ -1387,6 +1398,34 @@
     (let ([key (tui-event-key ev)]
           [ch (tui-event-ch ev)])
       (cond
+        ;; Mention navigation: when mention popup is active, arrow keys
+        ;; navigate, Enter/Tab selects, Esc cancels.
+        [(and (tui-state-mention-active? state)
+              (or (= key TB_KEY_ESC)
+                  (= key TB_KEY_TAB)
+                  (= key TB_KEY_ENTER)))
+         (if (= key TB_KEY_ESC)
+           (cancel-mention! state)
+           (if (or (= key TB_KEY_ENTER) (= key TB_KEY_TAB))
+             (select-mention! state)
+             (void)))]
+        [(and (tui-state-mention-active? state)
+              (= key TB_KEY_ARROW_UP))
+         (tui-state-mention-index-set! state
+           (clamp-index (- (tui-state-mention-index state) 1)
+                        (length (tui-state-mention-candidates state))))]
+        [(and (tui-state-mention-active? state)
+              (= key TB_KEY_ARROW_DOWN))
+         (tui-state-mention-index-set! state
+           (clamp-index (+ (tui-state-mention-index state) 1)
+                        (length (tui-state-mention-candidates state))))]
+        ;; Scroll keys (only in chat mode, no mention active)
+        [(or (= key TB_KEY_PGUP)
+             (= key TB_KEY_MOUSE_WHEEL_UP))
+         (scroll-thread! state 1)]
+        [(or (= key TB_KEY_PGDN)
+             (= key TB_KEY_MOUSE_WHEEL_DOWN))
+         (scroll-thread! state -1)]
         [(or (= key TB_KEY_CTRL_C)
              (= key TB_KEY_CTRL_Q))
          (tui-state-quit?-set! state #t)]
@@ -1408,6 +1447,7 @@
          (delete-input-char! state)]
         [(= key TB_KEY_CTRL_K)
          (tui-state-input-set! state "")
+         (cancel-mention! state)
          (tui-state-status-set! state "Composer cleared.")]
         [(= key TB_KEY_ARROW_UP)
          (select-conversation! state (- (tui-state-selected-index state) 1))]
@@ -1583,13 +1623,21 @@
   (def (append-input-char! state ch)
     (tui-state-input-set!
       state
-      (string-append (tui-state-input state) (string ch))))
+      (string-append (tui-state-input state) (string ch)))
+    (when (char=? ch #\@)
+      (activate-mention! state)))
 
   (def (delete-input-char! state)
     (let* ([s (tui-state-input state)]
            [n (string-length s)])
       (when (> n 0)
-        (tui-state-input-set! state (substring s 0 (- n 1))))))
+        (tui-state-input-set! state (substring s 0 (- n 1)))))
+    (when (tui-state-mention-active? state)
+      (let* ([mq (mention-query (tui-state-input state))]
+             [has-at? (and mq (not (string=? (cdr mq) "")))])
+        (if has-at?
+          (activate-mention! state)
+          (cancel-mention! state)))))
 
   (def (send-composer-message! state actor)
     (let ([conv (selected-conversation state)]
@@ -2543,6 +2591,7 @@
            (draw-details! state (+ details-x 1) 2 (- details-w 2) (- body-h 3)))
 
          (draw-composer! state 0 composer-y w)
+         (draw-mention-popup! state 0 composer-y w)
          (when (eq? (tui-state-mode state) 'confirm-delete)
            (draw-confirm-delete! state w h))])
       (draw-status! state 0 status-y w)
@@ -2780,9 +2829,11 @@
           (let* ([typing (active-typing-sender conv)]
                  [reserved (if typing 1 0)]
                  [rows (max 0 (- height 3 reserved))]
+                 [scroll (tui-state-thread-scroll state)]
                  [rendered (thread-render-rows (conversation-messages conv)
-                                               width
-                                               rows)])
+                                                width
+                                                rows
+                                                scroll)])
             (let loop ([rs rendered] [row (+ y 3)])
               (if (and (pair? rs) (< row (+ y 3 rows)))
                 (begin
@@ -2797,16 +2848,162 @@
           (draw-text! x (+ y 1) width (fg-dim) (bg)
                       "Waiting for Signal receive notifications.")))))
 
-  (def (thread-render-rows messages width rows)
+  (def (thread-render-rows messages width rows scroll)
     (if (<= rows 0)
       '()
-      (let loop ([ms (reverse messages)] [acc '()])
+      (let* ([all (let loop ([ms (reverse messages)] [acc '()])
+                    (if (null? ms)
+                      (reverse acc)
+                      (loop (cdr ms)
+                            (append (message-render-rows (car ms) width) acc))))]
+             [total (length all)])
+        (let loop ([i 0] [row 0] [rs all] [acc '()])
+          (cond
+            [(or (null? rs) (>= (length acc) rows))
+             (last-n-items acc rows)]
+            [(< row scroll)
+             (loop (+ i 1) (+ row 1) (cdr rs) acc)]
+            [else
+             (loop (+ i 1) (+ row 1) (cdr rs) (cons (car rs) acc))])))))
+
+  ;; Build every rendered row for the conversation, newest first.
+  (def (render-all-rows conv width)
+    (let loop ([ms (reverse (conversation-messages conv))]
+               [acc '()])
+      (if (null? ms)
+        (reverse acc)
+        (loop (cdr ms)
+              (append (message-render-rows (car ms) width) acc)))))
+
+  ;; Number of rows this conversation fills when fully rendered.
+  (def (total-thread-rows conv width)
+    (length (render-all-rows conv width)))
+
+  ;; Clamp scroll so the visible bottom never goes above the first row.
+  (def (max-thread-scroll conv width height)
+    (max 0 (- (total-thread-rows conv width) height)))
+
+  ;; Scroll the thread. Positive delta means show older messages.
+  (def (scroll-thread! state delta)
+    (let ([conv (selected-conversation state)])
+      (when conv
+        (let* ([w (max 10 (- (tui-state-width state)
+                             (if (< (tui-state-width state) 80) 20 26)
+                             2))]
+                [height (let* ([body-h (max 4 (- (tui-state-height state) 4))]
+                               [typing (active-typing-sender conv)]
+                               [reserved (if typing 1 0)])
+                          (max 0 (- body-h 3 reserved)))]
+                [max-scroll (max-thread-scroll conv w height)])
+          (tui-state-thread-scroll-set! state
+            (clamp-index (+ (tui-state-thread-scroll state) delta)
+                         (+ max-scroll 1)))))))
+
+  ;; --- @mention support ---
+
+  ;; All direct conversation titles (contact names) as mention candidates.
+  (def (mention-candidates state)
+    (let loop ([convs (tui-state-conversations state)]
+               [acc '()])
+      (cond
+        [(null? convs) (reverse acc)]
+        [else
+         (let ([conv (car convs)])
+           (loop (cdr convs)
+                 (if (eq? (conversation-kind conv) 'direct)
+                   (cons (conversation-title conv) acc)
+                   acc)))])))
+
+  ;; Filter candidates by the partial query after the @.
+  (def (filter-mentions candidates query)
+    (let ([needle (ascii-downcase query)])
+      (let loop ([xs candidates] [acc '()])
         (cond
-          [(or (null? ms) (>= (length acc) rows))
-           (last-n-items acc rows)]
+          [(null? xs) (reverse acc)]
           [else
-           (loop (cdr ms)
-                 (append (message-render-rows (car ms) width) acc))]))))
+           (let ([name (car xs)])
+             (loop (cdr xs)
+                   (if (string-contains? (ascii-downcase name) needle)
+                     (cons name acc)
+                     acc)))]))))
+
+  ;; Find the @ that triggered mention mode; return (pos . query) or #f.
+  (def (mention-query input)
+    (let* ([len (string-length input)]
+           [at-pos (find-last-at input (- len 1))])
+      (if at-pos
+        (let* ([query (substring input (+ at-pos 1) len)]
+               [trimmed (string-trim query)])
+          (if (string=? trimmed "")
+            (cons at-pos "")
+            (cons at-pos trimmed)))
+        #f)))
+
+  (def (find-last-at input i)
+    (cond
+      [(< i 0) #f]
+      [(char=? (string-ref input i) #\@) i]
+      [else (find-last-at input (- i 1))]))
+
+  (def (string-trim s)
+    (let* ([len (string-length s)]
+           [start (let loop ([i 0])
+                    (if (or (>= i len)
+                            (not (char-whitespace? (string-ref s i))))
+                      i
+                      (loop (+ i 1))))]
+           [end (let loop ([i (- len 1)])
+                  (if (or (< i 0)
+                          (not (char-whitespace? (string-ref s i))))
+                    (+ i 1)
+                    (- i 1)))])
+      (if (>= start end)
+        ""
+        (substring s start end))))
+
+  (def (char-whitespace? ch)
+    (or (char=? ch #\space)
+        (char=? ch #\tab)
+        (char=? ch #\newline)
+        (char=? ch #\return)))
+
+  ;; Activate mention mode: gather candidates filtered by the current query.
+  (def (activate-mention! state)
+    (let* ([input (tui-state-input state)]
+           [mq (mention-query input)])
+      (if mq
+        (let ([query (cdr mq)]
+              [candidates (filter-mentions (mention-candidates state)
+                                           (cdr mq))])
+          (tui-state-mention-active?-set! state #t)
+          (tui-state-mention-index-set! state 0)
+          (tui-state-mention-candidates-set! state candidates))
+        (cancel-mention! state))))
+
+  (def (cancel-mention! state)
+    (tui-state-mention-active?-set! state #f)
+    (tui-state-mention-index-set! state 0)
+    (tui-state-mention-candidates-set! state '()))
+
+  ;; Replace the @query text with the selected mention name + space.
+  (def (select-mention! state)
+    (let* ([candidates (tui-state-mention-candidates state)]
+           [count (length candidates)])
+      (if (= count 0)
+        (cancel-mention! state)
+        (let* ([name (list-ref candidates
+                               (clamp-index (tui-state-mention-index state)
+                                            count))]
+               [input (tui-state-input state)]
+               [mq (mention-query input)])
+          (when mq
+            (let* ([at-pos (car mq)]
+                   [before (substring input 0 at-pos)]
+                   [after (substring input (+ at-pos (string-length (cdr mq)) 1)
+                                     (string-length input))]
+                   [new-text (string-append before "@" name " " after)])
+              (tui-state-input-set! state new-text)))
+          (cancel-mention! state)))))
 
   (def (message-render-rows msg width)
     (let loop ([lines (chat-message->display-lines msg width)] [acc '()])
@@ -2838,6 +3035,25 @@
     (draw-text! (+ x 3) (+ y 1) (- width 4) (fg-strong) (input-bg)
                 (tui-state-input state)))
 
+  (def (draw-mention-popup! state x y width)
+    (let* ([candidates (tui-state-mention-candidates state)]
+           [count (length candidates)]
+           [popup-h (min 10 (+ 2 count))]
+           [popup-y (- y popup-h)])
+      (when (and (tui-state-mention-active? state) (> count 0))
+        (fill-rect! x popup-y width popup-h (fg) (selected-bg))
+        (draw-text! x popup-y width (fg-strong) (selected-bg) "Mentions")
+        (let loop ([xs candidates] [idx 0] [row (+ popup-y 1)])
+          (when (and (pair? xs) (< row (+ popup-y popup-h)))
+            (let* ([selected? (= idx (clamp-index (tui-state-mention-index state)
+                                                   count))]
+                   [fgc (if selected? (fg-accent) (fg))]
+                   [bgc (if selected? (selected-bg) (selected-bg))]
+                   [marker (if selected? "> " "  ")])
+              (draw-text! x row width fgc bgc
+                          (string-append marker (car xs)))
+              (loop (cdr xs) (+ idx 1) (+ row 1))))))))
+
   (def (draw-status! state x y width)
     (fill-rect! x y width 1 (status-fg) (status-bg))
     (draw-text! (+ x 1) y (- width 2) (status-fg) (status-bg)